diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /src | |
| download | slosh-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.c | 225 | ||||
| -rw-r--r-- | src/pane.c | 265 | ||||
| -rw-r--r-- | src/pty.c | 86 | ||||
| -rw-r--r-- | src/screen.c | 207 | ||||
| -rw-r--r-- | src/sl0ptty.h | 98 |
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 */ |
