aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /src
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'src')
-rw-r--r--src/main.c225
-rw-r--r--src/pane.c265
-rw-r--r--src/pty.c86
-rw-r--r--src/screen.c207
-rw-r--r--src/sl0ptty.h98
5 files changed, 881 insertions, 0 deletions
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..5514328
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,225 @@
+/* M0: one pty, one fullscreen pane, input passthrough, resize.
+ * Plus a seed of the headless harness (M0.5) so this is testable without a tty.
+ */
+#define _GNU_SOURCE
+#include "sl0ptty.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <termios.h>
+#include <time.h>
+#include <unistd.h>
+
+#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */
+
+static volatile sig_atomic_t g_winch = 0, g_quit = 0;
+static struct termios g_saved_tio;
+static bool g_tio_saved = false;
+
+static void on_signal(int sig) {
+ if (sig == SIGWINCH) g_winch = 1;
+ else g_quit = 1;
+}
+
+static void restore_tty(void) {
+ if (g_tio_saved) tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved_tio);
+ const char *leave = "\x1b[?25h\x1b[0m\x1b[?1049l";
+ ssize_t r = write(STDOUT_FILENO, leave, strlen(leave));
+ (void)r;
+}
+
+static int raw_mode(void) {
+ if (tcgetattr(STDIN_FILENO, &g_saved_tio) != 0) return -1;
+ g_tio_saved = true;
+ struct termios t = g_saved_tio;
+ cfmakeraw(&t);
+ t.c_cc[VMIN] = 1;
+ t.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) != 0) return -1;
+ atexit(restore_tty);
+ const char *enter = "\x1b[?1049h\x1b[0m\x1b[2J";
+ ssize_t r = write(STDOUT_FILENO, enter, strlen(enter));
+ (void)r;
+ return 0;
+}
+
+static void term_size(uint16_t *cols, uint16_t *rows) {
+ struct winsize ws;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
+ *cols = ws.ws_col;
+ *rows = ws.ws_row;
+ } else {
+ *cols = 80;
+ *rows = 24;
+ }
+}
+
+static int64_t now_ms(void) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ 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;
+}
+
+/* ---- interactive -------------------------------------------------------- */
+
+static int run_interactive(const char *const argv[]) {
+ uint16_t cols, rows;
+ term_size(&cols, &rows);
+
+ if (raw_mode() != 0) {
+ fprintf(stderr, "sl0ptty: stdin is not a terminal (try --headless)\n");
+ return 1;
+ }
+
+ 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);
+
+ bool pending_paint = true;
+ int64_t next_frame = 0;
+ bool prefix = false; /* M0 scaffolding: C-a q quits, C-a C-a sends C-a */
+
+ while (!g_quit && pane_alive(p)) {
+ int timeout = -1;
+ if (pending_paint) {
+ int64_t due = next_frame - now_ms();
+ timeout = due <= 0 ? 0 : (int)due;
+ }
+
+ struct pollfd pfds[2] = {
+ {.fd = STDIN_FILENO, .events = POLLIN},
+ {.fd = pane_fd(p), .events = POLLIN},
+ };
+ int n = poll(pfds, 2, timeout);
+
+ if (g_winch) {
+ g_winch = 0;
+ term_size(&cols, &rows);
+ screen_resize(&s, cols, rows);
+ pane_resize(p, cols, rows);
+ pending_paint = true;
+ next_frame = now_ms();
+ }
+
+ if (n < 0 && errno != EINTR) break;
+
+ if (n > 0 && (pfds[0].revents & POLLIN)) {
+ char buf[8192];
+ ssize_t r = read(STDIN_FILENO, buf, sizeof buf);
+ if (r > 0) {
+ for (ssize_t i = 0; i < r; i++) {
+ if (prefix) {
+ prefix = false;
+ if (buf[i] == 'q') { g_quit = 1; break; }
+ if (buf[i] == 0x01) { pane_write(p, "\x01", 1); continue; }
+ continue; /* unknown prefix command: swallow */
+ }
+ if (buf[i] == 0x01) { prefix = true; continue; }
+ pane_write(p, &buf[i], 1);
+ }
+ }
+ }
+
+ if (n > 0 && (pfds[1].revents & (POLLIN | POLLHUP))) {
+ pane_pump(p);
+ if (!pending_paint) {
+ pending_paint = true;
+ next_frame = now_ms() + FRAME_MS;
+ }
+ }
+
+ if (pending_paint && now_ms() >= next_frame) {
+ pane_compose(p, &s, 0, 0, true);
+ screen_flush(&s, STDOUT_FILENO);
+ pending_paint = false;
+ }
+ }
+
+ screen_free(&s);
+ pane_free(p);
+ restore_tty();
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ bool headless = false;
+ uint16_t cols = 80, rows = 24;
+ int idle_ms = 300;
+ const char *cmd_argv[64];
+ int cmd_n = 0;
+
+ for (int i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "--headless") == 0) headless = 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]);
+ else if (strcmp(argv[i], "--") == 0) {
+ for (int j = i + 1; j < argc && cmd_n < 63; j++) cmd_argv[cmd_n++] = argv[j];
+ break;
+ } else {
+ fprintf(stderr, "sl0ptty: unknown argument: %s\n", argv[i]);
+ return 2;
+ }
+ }
+
+ if (cmd_n == 0) {
+ const char *shell = getenv("SHELL");
+ cmd_argv[cmd_n++] = shell && *shell ? shell : "/bin/sh";
+ }
+ cmd_argv[cmd_n] = NULL;
+
+ struct sigaction sa = {.sa_handler = on_signal};
+ sigaction(SIGWINCH, &sa, NULL);
+ sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGHUP, &sa, NULL);
+ 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)
+ : run_interactive(cmd_argv);
+}
diff --git a/src/pane.c b/src/pane.c
new file mode 100644
index 0000000..ffa2aa4
--- /dev/null
+++ b/src/pane.c
@@ -0,0 +1,265 @@
+/* A pane: one pty, one libghostty-vt terminal, and the code that composites
+ * its viewport into the screen. */
+#define _GNU_SOURCE
+#include "sl0ptty.h"
+
+#include <errno.h>
+#include <ghostty/vt.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+struct pane {
+ pty_t pty;
+ GhosttyTerminal term;
+ GhosttyRenderState rstate;
+ GhosttyRenderStateRowIterator rows;
+ GhosttyRenderStateRowCells cells;
+ uint16_t cols, rows_n;
+ bool alive;
+ bool dirty;
+ char title[256];
+};
+
+/* Terminal replies (DA, cursor position, XTVERSION...) go back to the app. */
+static void on_write_pty(GhosttyTerminal t, void *ud, const uint8_t *data,
+ size_t len) {
+ pane_t *p = ud;
+ if (!p || p->pty.fd < 0) return;
+ size_t off = 0;
+ while (off < len) {
+ ssize_t n = write(p->pty.fd, data + off, len - off);
+ if (n <= 0) break;
+ off += (size_t)n;
+ }
+}
+
+static void on_title_changed(GhosttyTerminal t, void *ud) {
+ pane_t *p = ud;
+ GhosttyString s = {0};
+ if (ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_TITLE, &s) !=
+ GHOSTTY_SUCCESS)
+ return;
+ size_t n = s.len < sizeof p->title - 1 ? s.len : sizeof p->title - 1;
+ memcpy(p->title, s.ptr, n);
+ p->title[n] = 0;
+ p->dirty = true;
+}
+
+pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd) {
+ pane_t *p = calloc(1, sizeof *p);
+ p->cols = cols;
+ p->rows_n = rows;
+ p->pty.fd = -1;
+
+ if (ghostty_terminal_new(NULL, &p->term, cols, rows) != GHOSTTY_SUCCESS)
+ goto fail;
+ if (ghostty_render_state_new(NULL, &p->rstate) != GHOSTTY_SUCCESS) goto fail;
+ if (ghostty_render_state_row_iterator_new(NULL, &p->rows) != GHOSTTY_SUCCESS)
+ goto fail;
+ if (ghostty_render_state_row_cells_new(NULL, &p->cells) != GHOSTTY_SUCCESS)
+ goto fail;
+
+ void *self = p;
+ GhosttyTerminalWritePtyFn wfn = on_write_pty;
+ GhosttyTerminalTitleChangedFn tfn = on_title_changed;
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_USERDATA, &self);
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, &wfn);
+ ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, &tfn);
+
+ if (pty_spawn(&p->pty, argv, cols, rows, cwd) != 0) goto fail;
+ p->alive = true;
+ p->dirty = true;
+ return p;
+
+fail:
+ pane_free(p);
+ return NULL;
+}
+
+void pane_free(pane_t *p) {
+ if (!p) return;
+ if (p->pty.fd >= 0) pty_close(&p->pty);
+ if (p->cells) ghostty_render_state_row_cells_free(p->cells);
+ if (p->rows) ghostty_render_state_row_iterator_free(p->rows);
+ if (p->rstate) ghostty_render_state_free(p->rstate);
+ if (p->term) ghostty_terminal_free(p->term);
+ free(p);
+}
+
+int pane_fd(const pane_t *p) { return p->pty.fd; }
+bool pane_alive(const pane_t *p) { return p->alive; }
+bool pane_dirty(pane_t *p) { return p->dirty; }
+const char *pane_title(const pane_t *p) { return p->title; }
+
+ssize_t pane_pump(pane_t *p) {
+ uint8_t buf[65536];
+ ssize_t total = 0;
+ for (;;) {
+ ssize_t n = read(p->pty.fd, buf, sizeof buf);
+ if (n > 0) {
+ /* M4: a side-channel scanner for OSC 5577 hooks in here, before the
+ * bytes reach the terminal. */
+ ghostty_terminal_vt_write(p->term, buf, (size_t)n);
+ p->dirty = true;
+ total += n;
+ if ((size_t)n < sizeof buf) break; /* drained */
+ continue;
+ }
+ if (n == 0) {
+ p->alive = false;
+ return 0;
+ }
+ if (errno == EAGAIN || errno == EWOULDBLOCK) break;
+ if (errno == EINTR) continue;
+ p->alive = false;
+ return -1;
+ }
+ return total;
+}
+
+void pane_write(pane_t *p, const void *buf, size_t len) {
+ if (p->pty.fd < 0) return;
+ size_t off = 0;
+ while (off < len) {
+ ssize_t n = write(p->pty.fd, (const char *)buf + off, len - off);
+ if (n <= 0) {
+ if (n < 0 && (errno == EINTR || errno == EAGAIN)) continue;
+ break;
+ }
+ off += (size_t)n;
+ }
+}
+
+void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) {
+ if (cols == p->cols && rows == p->rows_n) return;
+ p->cols = cols;
+ p->rows_n = rows;
+ ghostty_terminal_resize(p->term, cols, rows, 0, 0);
+ pty_resize(&p->pty, cols, rows);
+ p->dirty = true;
+}
+
+static color_t to_color(const GhosttyColorRgb *c, bool ok) {
+ color_t out = {0};
+ if (ok) {
+ out.set = true;
+ out.r = c->r;
+ out.g = c->g;
+ out.b = c->b;
+ }
+ return out;
+}
+
+void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
+ bool focused) {
+ if (ghostty_render_state_update(p->rstate, p->term) != GHOSTTY_SUCCESS) return;
+
+ if (ghostty_render_state_get(p->rstate, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR,
+ &p->rows) != GHOSTTY_SUCCESS)
+ return;
+
+ uint16_t y = 0;
+ while (ghostty_render_state_row_iterator_next(p->rows)) {
+ if (y >= p->rows_n) break;
+ if (ghostty_render_state_row_get(p->rows, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS,
+ &p->cells) != GHOSTTY_SUCCESS) {
+ y++;
+ continue;
+ }
+
+ uint16_t x = 0;
+ while (ghostty_render_state_row_cells_next(p->cells)) {
+ if (x >= p->cols) break;
+ cell_t *dst = screen_at(s, (uint16_t)(x0 + x), (uint16_t)(y0 + y));
+ if (!dst) break;
+
+ GhosttyCell raw = {0};
+ GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW;
+ if (ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &raw) ==
+ GHOSTTY_SUCCESS)
+ ghostty_cell_get(raw, GHOSTTY_CELL_DATA_WIDE, &wide);
+
+ if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) {
+ dst->width = 0;
+ dst->len = 0;
+ x++;
+ continue;
+ }
+
+ char utf8[16] = {0};
+ GhosttyBuffer gb = {.ptr = (uint8_t *)utf8, .cap = sizeof utf8, .len = 0};
+ if (ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8,
+ &gb) != GHOSTTY_SUCCESS)
+ gb.len = 0;
+
+ GhosttyColorRgb fg = {0}, bg = {0};
+ bool have_fg = ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR,
+ &fg) == GHOSTTY_SUCCESS;
+ bool have_bg = ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR,
+ &bg) == GHOSTTY_SUCCESS;
+
+ uint16_t attrs = 0;
+ bool styled = false;
+ if (ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING,
+ &styled) == GHOSTTY_SUCCESS &&
+ styled) {
+ GhosttyStyle st = GHOSTTY_INIT_SIZED(GhosttyStyle);
+ if (ghostty_render_state_row_cells_get(
+ p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, &st) ==
+ GHOSTTY_SUCCESS) {
+ if (st.bold) attrs |= ATTR_BOLD;
+ if (st.faint) attrs |= ATTR_DIM;
+ if (st.italic) attrs |= ATTR_ITALIC;
+ if (st.underline) attrs |= ATTR_UNDERLINE;
+ if (st.blink) attrs |= ATTR_BLINK;
+ if (st.inverse) attrs |= ATTR_INVERSE;
+ if (st.invisible) attrs |= ATTR_INVISIBLE;
+ if (st.strikethrough) attrs |= ATTR_STRIKE;
+ }
+ }
+
+ size_t n = gb.len < sizeof dst->text ? gb.len : sizeof dst->text;
+ memset(dst->text, 0, sizeof dst->text);
+ if (n) memcpy(dst->text, utf8, n);
+ else { dst->text[0] = ' '; n = 1; }
+ dst->len = (uint8_t)n;
+ dst->width = wide == GHOSTTY_CELL_WIDE_WIDE ? 2 : 1;
+ dst->attrs = attrs;
+ dst->fg = to_color(&fg, have_fg);
+ dst->bg = to_color(&bg, have_bg);
+ x++;
+ }
+ y++;
+ }
+
+ if (focused) {
+ bool vis = false, has_pos = false;
+ uint16_t cxp = 0, cyp = 0;
+ ghostty_render_state_get(p->rstate, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE,
+ &vis);
+ ghostty_render_state_get(
+ p->rstate, GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, &has_pos);
+ if (vis && has_pos) {
+ ghostty_render_state_get(p->rstate,
+ GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cxp);
+ ghostty_render_state_get(p->rstate,
+ GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cyp);
+ s->cursor_visible = true;
+ s->cursor_x = (uint16_t)(x0 + cxp);
+ s->cursor_y = (uint16_t)(y0 + cyp);
+ } else {
+ s->cursor_visible = false;
+ }
+ }
+
+ GhosttyRenderStateDirty clean = GHOSTTY_RENDER_STATE_DIRTY_FALSE;
+ ghostty_render_state_set(p->rstate, GHOSTTY_RENDER_STATE_OPTION_DIRTY, &clean);
+ p->dirty = false;
+}
diff --git a/src/pty.c b/src/pty.c
new file mode 100644
index 0000000..5ce166e
--- /dev/null
+++ b/src/pty.c
@@ -0,0 +1,86 @@
+/* pty spawn. Hand-rolled rather than forkpty() so we need no libutil and
+ * behave identically under musl. */
+#define _GNU_SOURCE
+#include "sl0ptty.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <termios.h>
+#include <unistd.h>
+
+int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd) {
+ int master = posix_openpt(O_RDWR | O_NOCTTY);
+ if (master < 0) return -1;
+ if (grantpt(master) < 0 || unlockpt(master) < 0) {
+ close(master);
+ return -1;
+ }
+
+ char slave_name[128];
+ if (ptsname_r(master, slave_name, sizeof slave_name) != 0) {
+ close(master);
+ return -1;
+ }
+
+ struct winsize ws = {.ws_col = cols, .ws_row = rows};
+ ioctl(master, TIOCSWINSZ, &ws);
+
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(master);
+ return -1;
+ }
+
+ if (pid == 0) {
+ /* child: new session, pty becomes the controlling terminal */
+ setsid();
+ int slave = open(slave_name, O_RDWR);
+ if (slave < 0) _exit(127);
+ ioctl(slave, TIOCSCTTY, 0);
+ dup2(slave, STDIN_FILENO);
+ dup2(slave, STDOUT_FILENO);
+ dup2(slave, STDERR_FILENO);
+ if (slave > STDERR_FILENO) close(slave);
+ close(master);
+
+ if (cwd) { if (chdir(cwd) != 0) { /* keep going in the old cwd */ } }
+
+ /* We are opinionated about the outer terminal (D11), and we present the
+ * same contract inward. xterm-ghostty is what libghostty-vt implements. */
+ setenv("TERM", "xterm-ghostty", 1);
+ setenv("SL0PTTY", "1", 1);
+ unsetenv("ZELLIJ");
+
+ signal(SIGPIPE, SIG_DFL);
+ execvp(argv[0], (char *const *)argv);
+ _exit(127);
+ }
+
+ /* parent */
+ int flags = fcntl(master, F_GETFL, 0);
+ fcntl(master, F_SETFL, flags | O_NONBLOCK);
+ p->fd = master;
+ p->pid = pid;
+ return 0;
+}
+
+int pty_resize(pty_t *p, uint16_t cols, uint16_t rows) {
+ struct winsize ws = {.ws_col = cols, .ws_row = rows};
+ return ioctl(p->fd, TIOCSWINSZ, &ws);
+}
+
+void pty_close(pty_t *p) {
+ if (p->fd >= 0) close(p->fd);
+ p->fd = -1;
+ if (p->pid > 0) {
+ kill(p->pid, SIGHUP);
+ waitpid(p->pid, NULL, WNOHANG);
+ }
+ p->pid = -1;
+}
diff --git a/src/screen.c b/src/screen.c
new file mode 100644
index 0000000..73d2fb0
--- /dev/null
+++ b/src/screen.c
@@ -0,0 +1,207 @@
+/* The compositor: a cell buffer for the whole screen, and a diff that turns
+ * two of them into the minimal byte stream for the real terminal. */
+#define _GNU_SOURCE
+#include "sl0ptty.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+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;
+ while (cap < s->out_len + n) cap *= 2;
+ s->out = realloc(s->out, cap);
+ s->out_cap = cap;
+}
+
+static void out_bytes(screen_t *s, const char *b, size_t n) {
+ out_reserve(s, n);
+ memcpy(s->out + s->out_len, b, n);
+ s->out_len += n;
+}
+
+static void out_str(screen_t *s, const char *b) { out_bytes(s, b, strlen(b)); }
+
+static void out_fmt(screen_t *s, const char *fmt, ...) {
+ char buf[128];
+ va_list ap;
+ va_start(ap, fmt);
+ int n = vsnprintf(buf, sizeof buf, fmt, ap);
+ va_end(ap);
+ if (n > 0) out_bytes(s, buf, (size_t)n);
+}
+
+static cell_t blank_cell(void) {
+ cell_t c = {0};
+ c.text[0] = ' ';
+ c.len = 1;
+ c.width = 1;
+ return c;
+}
+
+void screen_init(screen_t *s, uint16_t cols, uint16_t rows) {
+ memset(s, 0, sizeof *s);
+ s->cols = cols;
+ s->rows = rows;
+ size_t n = (size_t)cols * rows;
+ s->cur = calloc(n, sizeof(cell_t));
+ s->prev = calloc(n, sizeof(cell_t));
+ s->force_full = true;
+ screen_clear(s);
+}
+
+void screen_free(screen_t *s) {
+ free(s->cur);
+ free(s->prev);
+ free(s->out);
+ memset(s, 0, sizeof *s);
+}
+
+void screen_resize(screen_t *s, uint16_t cols, uint16_t rows) {
+ if (cols == s->cols && rows == s->rows) return;
+ free(s->cur);
+ free(s->prev);
+ s->cols = cols;
+ s->rows = rows;
+ size_t n = (size_t)cols * rows;
+ s->cur = calloc(n, sizeof(cell_t));
+ s->prev = calloc(n, sizeof(cell_t));
+ s->force_full = true;
+ screen_clear(s);
+}
+
+void screen_clear(screen_t *s) {
+ size_t n = (size_t)s->cols * s->rows;
+ cell_t b = blank_cell();
+ for (size_t i = 0; i < n; i++) s->cur[i] = b;
+}
+
+cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y) {
+ if (x >= s->cols || y >= s->rows) return NULL;
+ return &s->cur[(size_t)y * s->cols + x];
+}
+
+void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
+ size_t len, color_t fg, color_t bg, uint16_t attrs) {
+ cell_t *c = screen_at(s, x, y);
+ if (!c) return;
+ if (len > sizeof c->text) len = sizeof c->text;
+ memcpy(c->text, txt, len);
+ c->len = (uint8_t)len;
+ c->fg = fg;
+ c->bg = bg;
+ c->attrs = attrs;
+}
+
+static bool color_eq(color_t a, color_t b) {
+ if (a.set != b.set) return false;
+ if (!a.set) return true;
+ return a.r == b.r && a.g == b.g && a.b == b.b;
+}
+
+static bool cell_eq(const cell_t *a, const cell_t *b) {
+ return a->len == b->len && a->width == b->width && a->attrs == b->attrs &&
+ memcmp(a->text, b->text, a->len) == 0 && color_eq(a->fg, b->fg) &&
+ color_eq(a->bg, b->bg);
+}
+
+/* Reset-first SGR: a few bytes more than the minimal encoding, and it cannot
+ * inherit a stale attribute. Emitted only when the style actually changes. */
+static void emit_sgr(screen_t *s, const cell_t *c) {
+ out_str(s, "\x1b[0");
+ if (c->attrs & ATTR_BOLD) out_str(s, ";1");
+ if (c->attrs & ATTR_DIM) out_str(s, ";2");
+ if (c->attrs & ATTR_ITALIC) out_str(s, ";3");
+ if (c->attrs & ATTR_UNDERLINE) out_str(s, ";4");
+ if (c->attrs & ATTR_BLINK) out_str(s, ";5");
+ if (c->attrs & ATTR_INVERSE) out_str(s, ";7");
+ if (c->attrs & ATTR_INVISIBLE) out_str(s, ";8");
+ if (c->attrs & ATTR_STRIKE) out_str(s, ";9");
+ if (c->fg.set) out_fmt(s, ";38;2;%u;%u;%u", c->fg.r, c->fg.g, c->fg.b);
+ if (c->bg.set) out_fmt(s, ";48;2;%u;%u;%u", c->bg.r, c->bg.g, c->bg.b);
+ out_str(s, "m");
+}
+
+static bool style_eq(const cell_t *a, const cell_t *b) {
+ return a->attrs == b->attrs && color_eq(a->fg, b->fg) && color_eq(a->bg, b->bg);
+}
+
+void screen_flush(screen_t *s, int fd) {
+ s->out_len = 0;
+ out_str(s, "\x1b[?25l"); /* hide cursor while painting */
+
+ if (s->force_full) {
+ out_str(s, "\x1b[0m\x1b[H\x1b[2J");
+ memset(s->prev, 0, (size_t)s->cols * s->rows * sizeof(cell_t));
+ }
+
+ bool have_pos = false, have_style = false;
+ uint16_t cx = 0, cy = 0;
+ cell_t style = {0};
+
+ for (uint16_t y = 0; y < s->rows; y++) {
+ for (uint16_t x = 0; x < s->cols; x++) {
+ cell_t *c = &s->cur[(size_t)y * s->cols + x];
+ cell_t *p = &s->prev[(size_t)y * s->cols + x];
+ if (c->width == 0) continue; /* tail of a wide cell: never painted */
+ if (cell_eq(c, p)) continue;
+
+ if (!have_pos || cx != x || cy != y) {
+ out_fmt(s, "\x1b[%u;%uH", y + 1, x + 1);
+ cx = x;
+ cy = y;
+ have_pos = true;
+ }
+ if (!have_style || !style_eq(c, &style)) {
+ emit_sgr(s, c);
+ style = *c;
+ have_style = true;
+ }
+ if (c->len) out_bytes(s, c->text, c->len);
+ else out_str(s, " ");
+
+ *p = *c;
+ cx += c->width ? c->width : 1;
+ if (cx >= s->cols) have_pos = false; /* wrap is terminal-dependent */
+ }
+ }
+
+ if (s->cursor_visible && s->cursor_x < s->cols && s->cursor_y < s->rows) {
+ out_fmt(s, "\x1b[%u;%uH", s->cursor_y + 1, s->cursor_x + 1);
+ out_str(s, "\x1b[?25h");
+ }
+
+ s->force_full = false;
+ if (s->out_len) {
+ size_t off = 0;
+ while (off < s->out_len) {
+ ssize_t n = write(fd, s->out + off, s->out_len - off);
+ if (n <= 0) break;
+ off += (size_t)n;
+ }
+ }
+}
+
+char *screen_dump(screen_t *s) {
+ size_t cap = (size_t)(s->cols + 1) * s->rows * 4 + 1;
+ char *buf = malloc(cap);
+ size_t o = 0;
+ for (uint16_t y = 0; y < s->rows; y++) {
+ 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(buf + o, c->text, c->len);
+ o += c->len;
+ } else {
+ buf[o++] = ' ';
+ }
+ }
+ buf[o++] = '\n';
+ }
+ buf[o] = 0;
+ return buf;
+}
diff --git a/src/sl0ptty.h b/src/sl0ptty.h
new file mode 100644
index 0000000..52b9a09
--- /dev/null
+++ b/src/sl0ptty.h
@@ -0,0 +1,98 @@
+/* sl0ptty — common types. See DESIGN.md. */
+#ifndef SL0PTTY_H
+#define SL0PTTY_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <sys/types.h>
+
+/* ---- screen: our composited cell buffer + diff emitter ---------------- */
+
+enum {
+ ATTR_BOLD = 1 << 0,
+ ATTR_DIM = 1 << 1,
+ ATTR_ITALIC = 1 << 2,
+ ATTR_UNDERLINE = 1 << 3,
+ ATTR_BLINK = 1 << 4,
+ ATTR_INVERSE = 1 << 5,
+ ATTR_INVISIBLE = 1 << 6,
+ ATTR_STRIKE = 1 << 7,
+};
+
+typedef struct {
+ bool set; /* false = terminal default */
+ uint8_t r, g, b;
+} color_t;
+
+/* A composited cell. `len` bytes of UTF-8; a grapheme cluster may be several
+ * codepoints, hence the buffer rather than a codepoint. width 0 means this is
+ * the tail half of a wide cell and must not be painted. */
+typedef struct {
+ char text[16];
+ uint8_t len;
+ uint8_t width;
+ uint16_t attrs;
+ color_t fg, bg;
+} cell_t;
+
+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;
+
+ /* where the real cursor goes after a flush */
+ bool cursor_visible;
+ uint16_t cursor_x, cursor_y;
+
+ /* output accumulator, flushed in one write() */
+ char *out;
+ size_t out_len, out_cap;
+} screen_t;
+
+void screen_init(screen_t *s, uint16_t cols, uint16_t rows);
+void screen_free(screen_t *s);
+void screen_resize(screen_t *s, uint16_t cols, uint16_t rows);
+void screen_clear(screen_t *s);
+cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y);
+void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
+ size_t len, color_t fg, color_t bg, uint16_t attrs);
+/* Diff cur against prev and write the minimal byte stream to fd. */
+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);
+
+/* ---- pty --------------------------------------------------------------- */
+
+typedef struct {
+ int fd;
+ pid_t pid;
+} pty_t;
+
+int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd);
+int pty_resize(pty_t *p, uint16_t cols, uint16_t rows);
+void pty_close(pty_t *p);
+
+/* ---- pane: a pty + a libghostty-vt terminal ---------------------------- */
+
+typedef struct pane pane_t;
+
+pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd);
+void pane_free(pane_t *p);
+int pane_fd(const pane_t *p);
+bool pane_alive(const pane_t *p);
+/* Read available pty output into the terminal. Returns bytes read, 0 on EOF,
+ * -1 on error (EAGAIN is reported as 0 bytes with alive still true). */
+ssize_t pane_pump(pane_t *p);
+void pane_write(pane_t *p, const void *buf, size_t len);
+void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
+bool pane_dirty(pane_t *p);
+const char *pane_title(const pane_t *p);
+/* Compose this pane's viewport into the screen at (x0,y0). Clears dirty. */
+void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
+ bool focused);
+
+#endif /* SL0PTTY_H */