diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:26:02 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:26:02 +0200 |
| commit | 34fcd16e59e3087e9bf84ebce4e05d439eea9198 (patch) | |
| tree | e07b9dfc235e3a0b67047d59a9c521c6e98a28ae | |
| parent | sl0ptty: input decoder + per-pane re-encode (diff) | |
| download | slosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.tar.gz slosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.tar.xz slosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.zip | |
sl0ptty: M0.5 — the headless driver and the harness everything rests on
--script runs the whole compositor with no tty: commands in (send, raw,
settle, resize, snapshot, alive), composited screen out as JSON — rows of
text, style runs, cursor, and the hit list.
Styles are emitted as runs rather than per-cell objects, because a test wants
'columns 0..5 of row 0 are bold red', not 1920 objects.
The hit list is empty until M1 paints something clickable, but the type, the
JSON, and hit_test() exist now: one geometry, established before there is
anything to get wrong.
Also fixes two host-policy defaults found by the cursor never appearing.
lib-vt is a VT library, not a terminal, so it starts with DECTCEM (mode 25)
off and grapheme clustering (2027) off. Both are now set via MODE_DEFAULT, so
they survive RIS.
tests/harness.py + tests/test_screen.py: 19 checks over composition, styling,
wide chars/clusters, scrollback, resize and the full input round trip —
including the kitty/legacy re-encoding matrix, which no longer needs a pty.
make test is now 2.4s for 59 checks; make test-live keeps the four that
genuinely need a tty.
| -rw-r--r-- | DESIGN.md | 35 | ||||
| -rw-r--r-- | Makefile | 9 | ||||
| -rw-r--r-- | src/headless.c | 164 | ||||
| -rw-r--r-- | src/json.c | 111 | ||||
| -rw-r--r-- | src/json.h | 28 | ||||
| -rw-r--r-- | src/main.c | 41 | ||||
| -rw-r--r-- | src/pane.c | 15 | ||||
| -rw-r--r-- | src/screen.c | 139 | ||||
| -rw-r--r-- | src/sl0ptty.h | 22 | ||||
| -rw-r--r-- | tests/__pycache__/harness.cpython-314.pyc | bin | 0 -> 7957 bytes | |||
| -rw-r--r-- | tests/harness.py | 132 | ||||
| -rw-r--r-- | tests/live_input.py | 109 | ||||
| -rw-r--r-- | tests/test_screen.py | 137 |
13 files changed, 817 insertions, 125 deletions
@@ -42,6 +42,21 @@ it is *not* a free OSC 5577 hook. Three consequences, all cheap: 3. Worth an upstream issue: a generic "unhandled OSC" callback. Mitchell's library, our use case, small patch. +### lib-vt assumes nothing a host terminal would + +It is a VT *library*, not a terminal, so host policy is ours to set. Found by +the cursor never appearing: + +- **DECTCEM (mode 25) starts off.** The cursor is invisible until the host says + otherwise. We set it via `MODE_DEFAULT`, which updates the current value *and* + the one restored by RIS, so a program that resets the terminal does not lose + its cursor. +- **Grapheme clustering (mode 2027) starts off**, so flags and ZWJ emoji occupy + several cells. We default it on, matching the outer terminal we assume (D11). + +Both are set in `pane_new()`. Expect more of these; the rule is that anything a +terminal emulator would decide is not decided for us. + ### What we own 1. **Input decoding.** lib-vt encodes events *out* to a pane; nothing decodes @@ -83,13 +98,19 @@ Drawing and hit-testing cannot disagree, because there is only one of them. From the sl0ppi roadmap, four separate times: *unit tests were necessary and insufficient.* Every real bug was found by reading the screen. -> **`sl0ptty --headless` runs the entire compositor with no tty, and dumps the -> composited screen — text, styles, cursor, hit-list — as JSON on the control -> socket.** +> **`sl0ptty --script` runs the entire compositor with no tty: commands in +> (`send`, `raw`, `settle`, `resize`, `snapshot`), and the composited screen out +> as JSON — rows of text, style runs, cursor, hit-list.** + +Tests are "drive these events, assert this screen". `make test` is **2.4s for +59 checks**; `make test-live` keeps the handful that genuinely need a tty (raw +mode, the diff emitter, SIGWINCH, the prefix key). The command vocabulary is +deliberately the one M2's control socket will speak, so the harness moves over +unchanged. -Tests are "drive these events, assert this screen", deterministic and -sub-second. This lands before splits do, because everything after it rests on -it. +Styles are emitted as **runs**, not per-cell objects: a test wants to say +"columns 0..5 of row 0 are bold red", and 1920 cell objects per snapshot is +unreadable. ## Decisions @@ -156,7 +177,7 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap - **M0.25** — input decoder + per-pane re-encode ✅ (pulled forward: retrofitting it under a layout tree would have been worse, and it is the load-bearing half of the mouse work in M4) -- **M0.5** — headless mode + screen-assert harness (seeded: `--headless`) +- **M0.5** — headless driver + screen-assert harness ✅ - **M1** — layout tree, splits, focus, frames (gap/padding/title alignment) - **M2** — server/client split, detach/reattach, control socket - **M3** — tabs, `purpose=`, JSON control API @@ -18,7 +18,7 @@ SRC := $(wildcard src/*.c) OBJ := $(SRC:src/%.c=build/%.o) BIN := build/sl0ptty -.PHONY: all clean vendor run test smoke help +.PHONY: all clean vendor run test test-live test-all smoke help .DEFAULT_GOAL := help help: ## show this @@ -46,13 +46,18 @@ TEST_BIN := build/input_test $(TEST_BIN): tests/input_test.c src/input.c $(VT_LIB) | build $(CC) $(CFLAGS) tests/input_test.c src/input.c $(VT_LIB) -o $@ -test: $(BIN) $(TEST_BIN) ## run unit + live checks +test: $(BIN) $(TEST_BIN) ## unit + headless checks (fast) ./$(TEST_BIN) @echo + cd tests && python3 test_screen.py + +test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @echo python3 tests/live_input.py +test-all: test test-live ## everything + smoke: $(BIN) ## compose a screen headlessly and print it ./$(BIN) --headless --cols 40 --rows 6 -- /bin/sh -c 'printf "hello \033[1;32msl0ptty\033[0m\n"; echo "wide: 日本語"' diff --git a/src/headless.c b/src/headless.c new file mode 100644 index 0000000..3da8ee7 --- /dev/null +++ b/src/headless.c @@ -0,0 +1,164 @@ +/* The headless driver (DESIGN.md, "headless from day one"). + * + * The whole compositor with no tty: commands in on stdin, screen snapshots out + * on stdout. Tests drive this instead of screen-scraping a pty, so they are + * deterministic and take milliseconds. + * + * The command set is deliberately the vocabulary the M2 control socket will + * speak, so the harness moves over unchanged. + * + * send <escaped> bytes as if typed at the outer terminal (decoded first) + * raw <escaped> bytes straight to the pane's pty, decoder bypassed + * settle [ms] pump the pane until it has been quiet for ms + * resize <c> <r> + * snapshot [json|text] + * quit + * + * <escaped> understands \e \n \r \t \\ \xHH, so a test can write \e[1;5A. + */ +#define _GNU_SOURCE +#include "sl0ptty.h" + +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +typedef struct { + pane_t *pane; +} hl_dispatch_t; + +static void hl_event(const input_event_t *ev, void *ud) { + hl_dispatch_t *d = ud; + switch (ev->kind) { + case EV_KEY: pane_send_key(d->pane, ev); break; + case EV_MOUSE: pane_send_mouse(d->pane, ev); break; + case EV_PASTE: pane_send_paste(d->pane, ev->paste, ev->paste_len); break; + default: break; + } +} + +static int64_t ms_now(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +/* \e \n \r \t \\ \xHH -> bytes */ +static size_t unescape(const char *in, uint8_t *out, size_t cap) { + size_t n = 0; + for (const char *p = in; *p && n < cap; p++) { + if (*p != '\\') { + out[n++] = (uint8_t)*p; + continue; + } + p++; + switch (*p) { + case 'e': out[n++] = 0x1b; break; + case 'n': out[n++] = '\n'; break; + case 'r': out[n++] = '\r'; break; + case 't': out[n++] = '\t'; break; + case '0': out[n++] = 0; break; + case '\\': out[n++] = '\\'; break; + case 'x': { + int hi = 0, lo = 0; + sscanf(p + 1, "%1x%1x", &hi, &lo); + out[n++] = (uint8_t)(hi * 16 + lo); + p += 2; + break; + } + case 0: return n; + default: out[n++] = (uint8_t)*p; break; + } + } + return n; +} + +/* Pump the pane until it has produced nothing for `quiet` ms. */ +static void settle(pane_t *p, int quiet) { + int64_t last = ms_now(); + int64_t deadline = last + 3000; + while (pane_alive(p) && ms_now() < deadline) { + struct pollfd pfd = {.fd = pane_fd(p), .events = POLLIN}; + int n = poll(&pfd, 1, 10); + if (n > 0) { + if (pane_pump(p) > 0) last = ms_now(); + if (!pane_alive(p)) break; + } + if (ms_now() - last >= quiet) break; + } +} + +int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, + int idle_ms, bool script) { + pane_t *p = pane_new(argv, cols, rows, NULL); + if (!p) { + fprintf(stderr, "sl0ptty: cannot spawn pane\n"); + return 1; + } + screen_t s; + screen_init(&s, cols, rows); + input_parser_t *in = input_new(); + hl_dispatch_t d = {.pane = p}; + + /* one-shot: run it, let it settle, print the screen */ + if (!script) { + settle(p, idle_ms); + pane_compose(p, &s, 0, 0, true); + char *dump = screen_dump(&s); + fputs(dump, stdout); + free(dump); + goto done; + } + + char line[4096]; + while (fgets(line, sizeof line, stdin)) { + line[strcspn(line, "\r\n")] = 0; + char *sp = strchr(line, ' '); + const char *arg = sp ? sp + 1 : ""; + if (sp) *sp = 0; + + if (strcmp(line, "send") == 0) { + uint8_t buf[4096]; + size_t n = unescape(arg, buf, sizeof buf); + input_feed(in, buf, n, hl_event, &d); + if (input_pending(in)) input_timeout(in, hl_event, &d); + } else if (strcmp(line, "raw") == 0) { + uint8_t buf[4096]; + size_t n = unescape(arg, buf, sizeof buf); + pane_write(p, buf, n); + } else if (strcmp(line, "settle") == 0) { + settle(p, *arg ? atoi(arg) : idle_ms); + } else if (strcmp(line, "resize") == 0) { + int c = cols, r = rows; + sscanf(arg, "%d %d", &c, &r); + cols = (uint16_t)c; + rows = (uint16_t)r; + screen_resize(&s, cols, rows); + pane_resize(p, cols, rows); + } else if (strcmp(line, "snapshot") == 0) { + pane_compose(p, &s, 0, 0, true); + char *dump = strcmp(arg, "text") == 0 ? screen_dump(&s) + : screen_dump_json(&s); + fputs(dump, stdout); + fputs("\n", stdout); + free(dump); + fflush(stdout); + } else if (strcmp(line, "alive") == 0) { + printf("%s\n", pane_alive(p) ? "true" : "false"); + fflush(stdout); + } else if (strcmp(line, "quit") == 0) { + break; + } else if (line[0]) { + fprintf(stderr, "sl0ptty: unknown command: %s\n", line); + } + } + +done: + input_free(in); + screen_free(&s); + pane_free(p); + return 0; +} diff --git a/src/json.c b/src/json.c new file mode 100644 index 0000000..efbe120 --- /dev/null +++ b/src/json.c @@ -0,0 +1,111 @@ +#define _GNU_SOURCE +#include "json.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static void reserve(json_t *j, size_t n) { + if (j->len + n <= j->cap) return; + size_t cap = j->cap ? j->cap : 4096; + while (cap < j->len + n) cap *= 2; + j->buf = realloc(j->buf, cap); + j->cap = cap; +} + +static void raw(json_t *j, const char *s, size_t n) { + reserve(j, n + 1); + memcpy(j->buf + j->len, s, n); + j->len += n; + j->buf[j->len] = 0; +} + +static void rawz(json_t *j, const char *s) { raw(j, s, strlen(s)); } + +void json_init(json_t *j) { + memset(j, 0, sizeof *j); + reserve(j, 4096); + j->buf[0] = 0; +} + +void json_free(json_t *j) { + free(j->buf); + memset(j, 0, sizeof *j); +} + +static void sep(json_t *j, const char *key) { + if (j->need_comma) rawz(j, ","); + if (key) { + rawz(j, "\""); + rawz(j, key); + rawz(j, "\":"); + } + j->need_comma = true; +} + +static void escaped(json_t *j, const char *s, size_t len) { + rawz(j, "\""); + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)s[i]; + switch (c) { + case '"': rawz(j, "\\\""); break; + case '\\': rawz(j, "\\\\"); break; + case '\n': rawz(j, "\\n"); break; + case '\r': rawz(j, "\\r"); break; + case '\t': rawz(j, "\\t"); break; + default: + if (c < 0x20) { + char b[8]; + snprintf(b, sizeof b, "\\u%04x", c); + rawz(j, b); + } else { + raw(j, (const char *)&c, 1); /* UTF-8 passes through */ + } + } + } + rawz(j, "\""); +} + +void json_obj_open(json_t *j, const char *key) { + sep(j, key); + rawz(j, "{"); + j->need_comma = false; +} + +void json_obj_close(json_t *j) { + rawz(j, "}"); + j->need_comma = true; +} + +void json_arr_open(json_t *j, const char *key) { + sep(j, key); + rawz(j, "["); + j->need_comma = false; +} + +void json_arr_close(json_t *j) { + rawz(j, "]"); + j->need_comma = true; +} + +void json_str(json_t *j, const char *key, const char *val, size_t len) { + sep(j, key); + escaped(j, val, len); +} + +void json_int(json_t *j, const char *key, long long val) { + sep(j, key); + char b[32]; + snprintf(b, sizeof b, "%lld", val); + rawz(j, b); +} + +void json_bool(json_t *j, const char *key, bool val) { + sep(j, key); + rawz(j, val ? "true" : "false"); +} + +void json_null(json_t *j, const char *key) { + sep(j, key); + rawz(j, "null"); +} diff --git a/src/json.h b/src/json.h new file mode 100644 index 0000000..b1acc43 --- /dev/null +++ b/src/json.h @@ -0,0 +1,28 @@ +/* A minimal JSON writer. No parser: we only ever emit. */ +#ifndef SL0PTTY_JSON_H +#define SL0PTTY_JSON_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +typedef struct { + char *buf; + size_t len, cap; + bool need_comma; +} json_t; + +void json_init(json_t *j); +void json_free(json_t *j); + +void json_obj_open(json_t *j, const char *key); /* key may be NULL in arrays */ +void json_obj_close(json_t *j); +void json_arr_open(json_t *j, const char *key); +void json_arr_close(json_t *j); + +void json_str(json_t *j, const char *key, const char *val, size_t len); +void json_int(json_t *j, const char *key, long long val); +void json_bool(json_t *j, const char *key, bool val); +void json_null(json_t *j, const char *key); + +#endif /* SL0PTTY_JSON_H */ @@ -81,39 +81,9 @@ static int64_t now_ms(void) { return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } -/* ---- headless: run a command, settle, dump the composited screen -------- */ - -static int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, - int idle_ms) { - pane_t *p = pane_new(argv, cols, rows, NULL); - if (!p) { - fprintf(stderr, "sl0ptty: cannot spawn pane\n"); - return 1; - } - screen_t s; - screen_init(&s, cols, rows); - - int64_t last_activity = now_ms(); - while (pane_alive(p)) { - struct pollfd pfd = {.fd = pane_fd(p), .events = POLLIN}; - int n = poll(&pfd, 1, 20); - if (n > 0) { - if (pane_pump(p) > 0) last_activity = now_ms(); - if (!pane_alive(p)) break; - } - if (now_ms() - last_activity > idle_ms) break; - } - pane_pump(p); - - pane_compose(p, &s, 0, 0, true); - char *dump = screen_dump(&s); - fputs(dump, stdout); - free(dump); - - screen_free(&s); - pane_free(p); - return 0; -} +/* headless lives in headless.c */ +int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, + int idle_ms, bool script); /* ---- interactive -------------------------------------------------------- */ @@ -241,7 +211,7 @@ static int run_interactive(const char *const argv[]) { } int main(int argc, char **argv) { - bool headless = false; + bool headless = false, script = false; uint16_t cols = 80, rows = 24; int idle_ms = 300; const char *cmd_argv[64]; @@ -249,6 +219,7 @@ int main(int argc, char **argv) { for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--headless") == 0) headless = true; + else if (strcmp(argv[i], "--script") == 0) headless = script = true; else if (strcmp(argv[i], "--cols") == 0 && i + 1 < argc) cols = (uint16_t)atoi(argv[++i]); else if (strcmp(argv[i], "--rows") == 0 && i + 1 < argc) rows = (uint16_t)atoi(argv[++i]); else if (strcmp(argv[i], "--idle-ms") == 0 && i + 1 < argc) idle_ms = atoi(argv[++i]); @@ -274,6 +245,6 @@ int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); signal(SIGCHLD, SIG_IGN); /* no zombies; EOF on the pty is our signal */ - return headless ? run_headless(cmd_argv, cols, rows, idle_ms) + return headless ? run_headless(cmd_argv, cols, rows, idle_ms, script) : run_interactive(cmd_argv); } @@ -78,6 +78,21 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, &wfn); ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, &tfn); + /* lib-vt makes no assumptions a host terminal would: it starts with DECTCEM + * (mode 25) off, so the cursor is invisible until someone says otherwise. + * MODE_DEFAULT sets the current value *and* the one restored by RIS, so a + * program that resets the terminal does not lose its cursor. + * + * 2027 is grapheme clustering: one cell per cluster, so flags and ZWJ emoji + * stay whole. We are opinionated about the outer terminal (D11) and it + * clusters too; under a terminal that does not, wide emoji may misalign. */ + static const uint16_t default_on[] = {25, 2027}; + for (size_t i = 0; i < sizeof default_on / sizeof *default_on; i++) { + GhosttyTerminalModeConfig mc = {.mode = ghostty_mode_new(default_on[i], false), + .value = true}; + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_MODE_DEFAULT, &mc); + } + if (pty_spawn(&p->pty, argv, cols, rows, cwd) != 0) goto fail; p->alive = true; p->dirty = true; diff --git a/src/screen.c b/src/screen.c index 73d2fb0..9d31640 100644 --- a/src/screen.c +++ b/src/screen.c @@ -9,6 +9,35 @@ #include <string.h> #include <unistd.h> +#include "json.h" + +/* ---- hit list ----------------------------------------------------------- */ + +void hit_reset(hitlist_t *hl) { hl->len = 0; } + +void hit_add(hitlist_t *hl, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + const char *action) { + if (hl->len == hl->cap) { + hl->cap = hl->cap ? hl->cap * 2 : 16; + hl->items = realloc(hl->items, hl->cap * sizeof *hl->items); + } + hit_t *e = &hl->items[hl->len++]; + e->x = x; + e->y = y; + e->w = w; + e->h = h; + snprintf(e->action, sizeof e->action, "%s", action); +} + +const char *hit_test(const hitlist_t *hl, uint16_t x, uint16_t y) { + for (size_t i = hl->len; i-- > 0;) { + const hit_t *e = &hl->items[i]; + if (x >= e->x && x < e->x + e->w && y >= e->y && y < e->y + e->h) + return e->action; + } + return NULL; +} + static void out_reserve(screen_t *s, size_t n) { if (s->out_len + n <= s->out_cap) return; size_t cap = s->out_cap ? s->out_cap : 8192; @@ -57,6 +86,7 @@ void screen_free(screen_t *s) { free(s->cur); free(s->prev); free(s->out); + free(s->hits.items); memset(s, 0, sizeof *s); } @@ -185,6 +215,115 @@ void screen_flush(screen_t *s, int fd) { } } +/* Style runs rather than per-cell objects: a test wants to say "columns 0..5 of + * row 0 are bold red", and 1920 cell objects per snapshot is unreadable. */ +static void dump_style_runs(screen_t *s, json_t *j) { + json_arr_open(j, "styles"); + for (uint16_t y = 0; y < s->rows; y++) { + uint16_t x = 0; + while (x < s->cols) { + cell_t *c = &s->cur[(size_t)y * s->cols + x]; + bool plain = !c->attrs && !c->fg.set && !c->bg.set; + if (plain) { + x++; + continue; + } + uint16_t start = x; + while (x < s->cols) { + cell_t *n = &s->cur[(size_t)y * s->cols + x]; + if (n->attrs != c->attrs || !color_eq(n->fg, c->fg) || + !color_eq(n->bg, c->bg)) + break; + x++; + } + json_obj_open(j, NULL); + json_int(j, "x", start); + json_int(j, "y", y); + json_int(j, "w", x - start); + char col[8]; + if (c->fg.set) { + snprintf(col, sizeof col, "#%02x%02x%02x", c->fg.r, c->fg.g, c->fg.b); + json_str(j, "fg", col, strlen(col)); + } else { + json_null(j, "fg"); + } + if (c->bg.set) { + snprintf(col, sizeof col, "#%02x%02x%02x", c->bg.r, c->bg.g, c->bg.b); + json_str(j, "bg", col, strlen(col)); + } else { + json_null(j, "bg"); + } + json_arr_open(j, "attrs"); + static const struct { + uint16_t bit; + const char *name; + } names[] = { + {ATTR_BOLD, "bold"}, {ATTR_DIM, "dim"}, + {ATTR_ITALIC, "italic"}, {ATTR_UNDERLINE, "underline"}, + {ATTR_BLINK, "blink"}, {ATTR_INVERSE, "inverse"}, + {ATTR_INVISIBLE, "invisible"}, {ATTR_STRIKE, "strike"}, + }; + for (size_t i = 0; i < sizeof names / sizeof *names; i++) + if (c->attrs & names[i].bit) + json_str(j, NULL, names[i].name, strlen(names[i].name)); + json_arr_close(j); + json_obj_close(j); + } + } + json_arr_close(j); +} + +char *screen_dump_json(screen_t *s) { + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_int(&j, "cols", s->cols); + json_int(&j, "rows", s->rows); + + json_arr_open(&j, "text"); + char *line = malloc((size_t)s->cols * 4 + 1); + for (uint16_t y = 0; y < s->rows; y++) { + size_t o = 0; + for (uint16_t x = 0; x < s->cols; x++) { + cell_t *c = &s->cur[(size_t)y * s->cols + x]; + if (c->width == 0) continue; + if (c->len) { + memcpy(line + o, c->text, c->len); + o += c->len; + } else { + line[o++] = ' '; + } + } + json_str(&j, NULL, line, o); + } + free(line); + json_arr_close(&j); + + dump_style_runs(s, &j); + + json_obj_open(&j, "cursor"); + json_bool(&j, "visible", s->cursor_visible); + json_int(&j, "x", s->cursor_x); + json_int(&j, "y", s->cursor_y); + json_obj_close(&j); + + json_arr_open(&j, "hits"); + for (size_t i = 0; i < s->hits.len; i++) { + hit_t *e = &s->hits.items[i]; + json_obj_open(&j, NULL); + json_int(&j, "x", e->x); + json_int(&j, "y", e->y); + json_int(&j, "w", e->w); + json_int(&j, "h", e->h); + json_str(&j, "action", e->action, strlen(e->action)); + json_obj_close(&j); + } + json_arr_close(&j); + + json_obj_close(&j); + return j.buf; /* caller frees */ +} + char *screen_dump(screen_t *s) { size_t cap = (size_t)(s->cols + 1) * s->rows * 4 + 1; char *buf = malloc(cap); diff --git a/src/sl0ptty.h b/src/sl0ptty.h index c71ec9e..962de66 100644 --- a/src/sl0ptty.h +++ b/src/sl0ptty.h @@ -38,11 +38,31 @@ typedef struct { color_t fg, bg; } cell_t; +/* One geometry (DESIGN.md): every painted interactive element records its rect + * here as it is painted, and a click is a lookup. Drawing and hit-testing + * cannot disagree because there is only one of them. */ +typedef struct { + uint16_t x, y, w, h; + char action[48]; +} hit_t; + +typedef struct { + hit_t *items; + size_t len, cap; +} hitlist_t; + +void hit_reset(hitlist_t *hl); +void hit_add(hitlist_t *hl, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + const char *action); +/* The action at a point, or NULL. Later entries win: painted last is on top. */ +const char *hit_test(const hitlist_t *hl, uint16_t x, uint16_t y); + typedef struct { uint16_t cols, rows; cell_t *cur; /* what we want on screen */ cell_t *prev; /* what the terminal last received */ bool force_full; + hitlist_t hits; /* where the real cursor goes after a flush */ bool cursor_visible; @@ -64,6 +84,8 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt, void screen_flush(screen_t *s, int fd); /* Plain-text dump of the composited screen; caller frees. (headless tests) */ char *screen_dump(screen_t *s); +/* The same screen as JSON: rows of text, style runs, cursor, hit-list. */ +char *screen_dump_json(screen_t *s); /* ---- pty --------------------------------------------------------------- */ diff --git a/tests/__pycache__/harness.cpython-314.pyc b/tests/__pycache__/harness.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..a6f472f --- /dev/null +++ b/tests/__pycache__/harness.cpython-314.pyc diff --git a/tests/harness.py b/tests/harness.py new file mode 100644 index 0000000..617faf6 --- /dev/null +++ b/tests/harness.py @@ -0,0 +1,132 @@ +"""Drive sl0ptty's headless driver and assert on the composited screen. + + with Session(["/bin/sh", "-c", "echo hi"], cols=20, rows=3) as s: + s.settle() + assert s.snapshot().line(0).strip() == "hi" + +No pty, no sleeps beyond settling, no screen scraping: the compositor tells us +what it composed. This is the harness DESIGN.md asks for, and everything from +M1 on is tested through it. +""" +import json +import os +import subprocess + +BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "build", "sl0ptty") + + +class Snapshot: + def __init__(self, data): + self.data = data + self.text = data["text"] + self.cursor = data["cursor"] + self.styles = data["styles"] + self.hits = data["hits"] + self.cols = data["cols"] + self.rows = data["rows"] + + def line(self, y): + return self.text[y] + + def screen(self): + return "\n".join(self.text) + + def find(self, needle): + """(x, y) of the first occurrence, or None.""" + for y, row in enumerate(self.text): + x = row.find(needle) + if x >= 0: + return x, y + return None + + def style_at(self, x, y): + """The style run covering a cell, or None if the cell is unstyled.""" + for run in self.styles: + if run["y"] == y and run["x"] <= x < run["x"] + run["w"]: + return run + return None + + def hit_at(self, x, y): + """The action registered at a cell, last painted winning.""" + for e in reversed(self.hits): + if e["x"] <= x < e["x"] + e["w"] and e["y"] <= y < e["y"] + e["h"]: + return e["action"] + return None + + def __str__(self): + return self.screen() + + +class Session: + def __init__(self, argv, cols=80, rows=24): + self.cols, self.rows = cols, rows + self.proc = subprocess.Popen( + [BIN, "--script", "--cols", str(cols), "--rows", str(rows), "--"] + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + + # -- commands --------------------------------------------------------- + def _cmd(self, line): + self.proc.stdin.write(line + "\n") + self.proc.stdin.flush() + + def send(self, escaped): + """Bytes as if typed at the outer terminal (decoded, then re-encoded).""" + self._cmd(f"send {escaped}") + + def raw(self, escaped): + """Bytes straight into the pane's pty, decoder bypassed.""" + self._cmd(f"raw {escaped}") + + def settle(self, ms=120): + self._cmd(f"settle {ms}") + + def resize(self, cols, rows): + self.cols, self.rows = cols, rows + self._cmd(f"resize {cols} {rows}") + + def snapshot(self): + self._cmd("snapshot json") + line = self.proc.stdout.readline() + if not line: + raise RuntimeError("sl0ptty exited before answering snapshot") + return Snapshot(json.loads(line)) + + def alive(self): + self._cmd("alive") + return self.proc.stdout.readline().strip() == "true" + + def close(self): + try: + self._cmd("quit") + self.proc.stdin.close() + self.proc.wait(timeout=5) + except Exception: + self.proc.kill() + + def __enter__(self): + return self + + def __exit__(self, *a): + self.close() + + +# -- tiny test runner ------------------------------------------------------ + +_fails = [] + + +def check(name, cond, detail=""): + if cond: + print(f"ok {name}") + else: + _fails.append(name) + print(f"FAIL {name} <- {detail}") + + +def report(): + print(f"\n{'FAILED' if _fails else 'all green'} ({len(_fails)} failures)") + return 1 if _fails else 0 diff --git a/tests/live_input.py b/tests/live_input.py index 30dd12b..31dade8 100644 --- a/tests/live_input.py +++ b/tests/live_input.py @@ -1,27 +1,27 @@ #!/usr/bin/env python3 -"""Live check of decode -> re-encode. +"""The interactive client's input path, which headless cannot cover. -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. +The encoding matrix (kitty/legacy/mouse/paste, per pane) lives in +tests/test_screen.py, where it runs deterministically through the headless +driver. What is left here is the part that only exists with a real tty: raw +mode, reading stdin, and the prefix key. """ 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"] +fails = 0 -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 check(name, cond, detail=""): + global fails + if not cond: + fails += 1 + print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}") -def drain(fd, idle=0.3, limit=3.0): + +def drain(fd, idle=0.25, limit=2.0): buf, last, start = b"", time.time(), time.time() while time.time() - start < limit: r, _, _ = select.select([fd], [], [], 0.05) @@ -39,79 +39,26 @@ def drain(fd, idle=0.3, limit=3.0): 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() + pid, fd = pty.fork() + if pid == 0: + os.environ["TERM"] = "xterm-ghostty" + os.execv(BIN, [BIN] + INNER) + os._exit(127) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 10, 80, 0, 0)) 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"\x1b[1;5A") + out = drain(fd).decode("utf-8", "replace") + check("a key typed at a real tty reaches the pane", "^[[1;5A" in out, repr(out[-60:])) - 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"\x01x") # prefix + unbound + out = drain(fd) + check("prefix + unbound key is swallowed", out == b"", repr(out[-60:])) - 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"\x01\x01") # prefix + prefix = literal + out = drain(fd).decode("utf-8", "replace") + check("C-a C-a sends a literal ^A", "^A" in out, repr(out[-60:])) os.write(fd, b"\x01q") drain(fd, idle=0.2, limit=1.0) diff --git a/tests/test_screen.py b/tests/test_screen.py new file mode 100644 index 0000000..4156a0e --- /dev/null +++ b/tests/test_screen.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Composition, styling, cursor, scrollback, resize, and the input round trip. + +Everything here runs through the headless driver, so it is deterministic and +takes milliseconds. Contrast tests/live_*.py, which exist only for the parts +that genuinely need a tty. +""" +import sys + +from harness import Session, check, report + +CAT = ["/bin/sh", "-c", "stty raw -echo; cat -v"] +SH = lambda script: ["/bin/sh", "-c", script] + + +def test_text_and_cursor(): + with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s: + s.settle() + s.raw("hello") + s.settle() + snap = s.snapshot() + check("text lands on row 0", snap.line(0).startswith("hello"), repr(snap.line(0))) + check("cursor follows the text", snap.cursor == {"visible": True, "x": 5, "y": 0}, + str(snap.cursor)) + + s.raw("\\r\\nsecond") + s.settle() + snap = s.snapshot() + check("newline moves to row 1", snap.line(1).startswith("second"), repr(snap.line(1))) + check("cursor on row 1", (snap.cursor["x"], snap.cursor["y"]) == (6, 1), + str(snap.cursor)) + + +def test_styles(): + with Session(SH(r'printf "n\033[1mB\033[31mR\033[0mn"; sleep 5'), cols=10, rows=2) as s: + s.settle() + snap = s.snapshot() + check("style: plain cell has no run", snap.style_at(0, 0) is None, + str(snap.style_at(0, 0))) + b = snap.style_at(1, 0) + check("style: bold is recorded", b and b["attrs"] == ["bold"], str(b)) + r = snap.style_at(2, 0) + check("style: colour is resolved to rgb", + r and r["fg"] is not None and "bold" in r["attrs"], str(r)) + check("style: reset ends the run", snap.style_at(3, 0) is None, + str(snap.style_at(3, 0))) + + +def test_wide_and_clusters(): + with Session(SH('printf "[\\346\\227\\245]"; sleep 5'), cols=10, rows=1) as s: + s.settle() + snap = s.snapshot() + check("wide char occupies one cell in the dump", snap.line(0).startswith("[日]"), + repr(snap.line(0))) + + +def test_scrollback(): + with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s: + s.settle() + s.raw("1\\r\\n2\\r\\n3\\r\\n4\\r\\n5") + s.settle() + snap = s.snapshot() + check("viewport shows the tail, not the head", + snap.line(0).startswith("3") and snap.line(2).startswith("5"), + repr(snap.text)) + + +def test_resize(): + with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s: + s.settle() + s.resize(40, 5) + s.settle() + snap = s.snapshot() + check("resize changes the composited size", + snap.cols == 40 and snap.rows == 5 and len(snap.line(0)) == 40, + f"{snap.cols}x{snap.rows} line={len(snap.line(0))}") + + s.raw("\\033[999;999H\\033[6n") # ask the pane where it thinks it is + s.settle() + check("pane still responds after resize", s.alive()) + + +def test_input_round_trip(): + """The decoder and the per-pane encoder, without a pty in sight.""" + with Session(CAT, cols=40, rows=4) as s: + s.settle() + s.send(r"\e[1;5A") # legacy ctrl-up + s.settle() + check("legacy key reaches the pane", "^[[1;5A" in s.snapshot().screen()) + + with Session(CAT, cols=40, rows=4) as s: + s.settle() + s.send(r"\e[98;5u") # kitty ctrl-b -> legacy pane + s.settle() + check("kitty key is re-encoded for a legacy pane", + "^B" in s.snapshot().screen()) + + with Session(CAT, cols=40, rows=4) as s: + s.settle() + s.send(r"\e[200~pasted\e[201~") + s.settle() + check("paste is unwrapped for a pane that did not ask", + "pasted" in s.snapshot().screen()) + + kitty = ["/bin/sh", "-c", + 'stty raw -echo; printf "\\033[>1u\\033[?1000h\\033[?1006h"; cat -v'] + with Session(kitty, cols=40, rows=4) as s: + s.settle() + s.send(r"\x02") # legacy ctrl-b -> kitty pane + s.settle() + check("legacy key is re-encoded for a kitty pane", + "^[[98;5u" in s.snapshot().screen(), s.snapshot().screen()) + + s.send(r"\e[<0;7;3M") + s.settle() + check("mouse reaches a pane that asked for it", + "^[[<0;7;3M" in s.snapshot().screen()) + + +def test_hitlist_contract(): + """Empty until M1 paints something clickable, but the contract is live.""" + with Session(SH("sleep 5"), cols=10, rows=2) as s: + s.settle() + snap = s.snapshot() + check("hit list exists and is empty", snap.hits == [], str(snap.hits)) + check("hit test on empty list is None", snap.hit_at(0, 0) is None) + + +if __name__ == "__main__": + test_text_and_cursor() + test_styles() + test_wide_and_clusters() + test_scrollback() + test_resize() + test_input_round_trip() + test_hitlist_contract() + sys.exit(report()) |
