aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:26:02 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:26:02 +0200
commit34fcd16e59e3087e9bf84ebce4e05d439eea9198 (patch)
treee07b9dfc235e3a0b67047d59a9c521c6e98a28ae
parentsl0ptty: input decoder + per-pane re-encode (diff)
downloadslosh-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.md35
-rw-r--r--Makefile9
-rw-r--r--src/headless.c164
-rw-r--r--src/json.c111
-rw-r--r--src/json.h28
-rw-r--r--src/main.c41
-rw-r--r--src/pane.c15
-rw-r--r--src/screen.c139
-rw-r--r--src/sl0ptty.h22
-rw-r--r--tests/__pycache__/harness.cpython-314.pycbin0 -> 7957 bytes
-rw-r--r--tests/harness.py132
-rw-r--r--tests/live_input.py109
-rw-r--r--tests/test_screen.py137
13 files changed, 817 insertions, 125 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 848c3c2..df70e3f 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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
diff --git a/Makefile b/Makefile
index bbd676c..070b582 100644
--- a/Makefile
+++ b/Makefile
@@ -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 */
diff --git a/src/main.c b/src/main.c
index a2a587c..1c097f2 100644
--- a/src/main.c
+++ b/src/main.c
@@ -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);
}
diff --git a/src/pane.c b/src/pane.c
index f5eeac7..5850a57 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -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
new file mode 100644
index 0000000..a6f472f
--- /dev/null
+++ b/tests/__pycache__/harness.cpython-314.pyc
Binary files differ
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())