diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:35:57 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:35:57 +0200 |
| commit | d2940b9643496ed898450f8bd2c640aeb6f8b7a1 (patch) | |
| tree | f98ca5f6c4fb2ba632c08edaab09c917d199316c /src | |
| parent | chore: stop tracking __pycache__ (diff) | |
| download | slosh-d2940b9643496ed898450f8bd2c640aeb6f8b7a1.tar.gz slosh-d2940b9643496ed898450f8bd2c640aeb6f8b7a1.tar.xz slosh-d2940b9643496ed898450f8bd2c640aeb6f8b7a1.zip | |
sl0ptty: M1 — layout tree, splits, focus, frames, and the hit list in use
app_t owns the tree, focus, the prefix and what a key means; the interactive
client and the headless driver are now both thin front ends over it, so there
is exactly one implementation of every command.
Layout is the pure function D6 promised: layout(tree, rect) recomputed every
frame, remainder spread across children so nothing drifts, and splitting in a
direction that already exists grows that split rather than nesting — three
splits give three equal columns, not 1/2 + 1/4 + 1/4.
Chrome: rounded frames, aspect-corrected gap, centred titles. The split button
is budgeted BEFORE the title and hit-tested from the rect it was drawn in,
which is the fix the fork needed four attempts to land.
Every painted element registers (rect, action) as it is painted; a click is a
lookup. Mouse into a pane is translated to pane-local coordinates.
Three bugs the harness caught, none of which unit tests would have:
- one click on the split button split twice: press *and* release both fired.
Chrome now acts on press; forwarding to a pane still gets every event.
- callbacks were registered as &fn, but the option is pointer-typed, so the
stored 'callback' was a stack address. Any OSC 2 or DSR jumped into a dead
frame. Now a hard segfault turned into two regression tests.
- a keystroke that changed nothing still emitted a cursor hide/show pair.
The cursor is hidden lazily now, so an idle frame costs zero bytes.
96 checks green in 6s (40 unit, 23 screen, 33 layout), plus 9 live.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 600 | ||||
| -rw-r--r-- | src/app.h | 41 | ||||
| -rw-r--r-- | src/headless.c | 73 | ||||
| -rw-r--r-- | src/main.c | 83 | ||||
| -rw-r--r-- | src/pane.c | 17 | ||||
| -rw-r--r-- | src/screen.c | 52 | ||||
| -rw-r--r-- | src/sl0ptty.h | 6 |
7 files changed, 779 insertions, 93 deletions
diff --git a/src/app.c b/src/app.c new file mode 100644 index 0000000..b668b62 --- /dev/null +++ b/src/app.c @@ -0,0 +1,600 @@ +/* The layout tree, focus, chrome, and what keys do. See app.h. */ +#define _GNU_SOURCE +#include "app.h" + +#include <ghostty/vt.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "json.h" + +/* ---- config (compiled defaults for now; D2's parser lands in M3) -------- */ + +typedef struct { + uint16_t gap; /* blank rows between panes and at the screen edge */ + uint16_t gap_aspect; /* columns per row: cells are ~2x taller than wide */ + uint16_t pad; /* rows/cols between a frame and its content */ + bool rounded; + enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } title_align; +} config_t; + +static const config_t CFG = { + .gap = 1, .gap_aspect = 2, .pad = 0, .rounded = true, + .title_align = ALIGN_CENTER, +}; + +static const color_t FRAME_FOCUS = {true, 0xff, 0x5f, 0xd7}; +static const color_t FRAME_IDLE = {true, 0x45, 0x45, 0x4a}; +static const color_t TITLE_FOCUS = {true, 0xff, 0xff, 0xff}; +static const color_t NO_COLOR = {0}; + +/* ---- tree --------------------------------------------------------------- */ + +struct node { + enum { NODE_LEAF, NODE_SPLIT } kind; + node_t *parent; + rect_t rect; /* recomputed every layout pass; never trusted between them */ + + /* leaf */ + pane_t *pane; + uint32_t id; + rect_t content; /* where the pane's cells go */ + + /* split */ + split_dir_t dir; + node_t **kids; + size_t nkids; +}; + +struct app { + node_t *root; + node_t *focus; + uint32_t next_id; + uint16_t cols, rows; + bool prefix; + bool quit; + const char *const *argv; + /* the screen we last composed into: its hit list is what a click resolves + * against, so routing can never consult geometry the user never saw */ + const screen_t *painted; +}; + +static node_t *leaf_new(app_t *a) { + pane_t *p = pane_new(a->argv, 1, 1, NULL); + if (!p) return NULL; + node_t *n = calloc(1, sizeof *n); + n->kind = NODE_LEAF; + n->pane = p; + n->id = ++a->next_id; + return n; +} + +static void node_free(node_t *n) { + if (!n) return; + if (n->kind == NODE_LEAF) { + pane_free(n->pane); + } else { + for (size_t i = 0; i < n->nkids; i++) node_free(n->kids[i]); + free(n->kids); + } + free(n); +} + +app_t *app_new(const char *const argv[], uint16_t cols, uint16_t rows) { + app_t *a = calloc(1, sizeof *a); + a->argv = argv; + a->cols = cols; + a->rows = rows; + a->root = leaf_new(a); + if (!a->root) { + free(a); + return NULL; + } + a->focus = a->root; + return a; +} + +void app_free(app_t *a) { + if (!a) return; + node_free(a->root); + free(a); +} + +bool app_should_quit(const app_t *a) { return a->quit || a->root == NULL; } + +/* ---- walking ------------------------------------------------------------ */ + +typedef void (*leaf_fn)(node_t *, void *); + +static void walk(node_t *n, leaf_fn fn, void *ud) { + if (!n) return; + if (n->kind == NODE_LEAF) { + fn(n, ud); + return; + } + for (size_t i = 0; i < n->nkids; i++) walk(n->kids[i], fn, ud); +} + +static void count_cb(node_t *n, void *ud) { (*(size_t *)ud)++; } + +size_t app_pane_count(const app_t *a) { + size_t n = 0; + walk(a->root, count_cb, &n); + return n; +} + +struct find_fd { + int fd; + node_t *found; +}; +static void find_fd_cb(node_t *n, void *ud) { + struct find_fd *f = ud; + if (pane_fd(n->pane) == f->fd) f->found = n; +} + +struct collect { + int *out; + size_t n, max; +}; +static void collect_cb(node_t *n, void *ud) { + struct collect *c = ud; + if (c->n < c->max) c->out[c->n++] = pane_fd(n->pane); +} + +size_t app_fds(app_t *a, int *out, size_t max) { + struct collect c = {out, 0, max}; + walk(a->root, collect_cb, &c); + return c.n; +} + +bool app_pump_fd(app_t *a, int fd) { + struct find_fd f = {fd, NULL}; + walk(a->root, find_fd_cb, &f); + if (!f.found) return false; + pane_pump(f.found->pane); + return true; +} + +/* ---- layout: a pure function of the tree and the rect ------------------- */ + +static void layout_node(node_t *n, rect_t r) { + n->rect = r; + if (n->kind == NODE_LEAF) { + /* content is the frame deflated by its border and padding; a rect too + * small for a frame gets none, and the pane takes the whole thing. */ + uint16_t bx = r.w >= 3 && r.h >= 3 ? 1 + CFG.pad * CFG.gap_aspect : 0; + uint16_t by = r.w >= 3 && r.h >= 3 ? 1 + CFG.pad : 0; + n->content = (rect_t){ + .x = (uint16_t)(r.x + bx), + .y = (uint16_t)(r.y + by), + .w = (uint16_t)(r.w > 2 * bx ? r.w - 2 * bx : 1), + .h = (uint16_t)(r.h > 2 * by ? r.h - 2 * by : 1), + }; + pane_resize(n->pane, n->content.w, n->content.h); + return; + } + + size_t k = n->nkids; + uint16_t gap = n->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) + : CFG.gap; + uint16_t total = n->dir == SPLIT_COLS ? r.w : r.h; + uint16_t gaps = (uint16_t)(gap * (k - 1)); + uint16_t avail = total > gaps ? (uint16_t)(total - gaps) : (uint16_t)k; + uint16_t each = (uint16_t)(avail / k); + uint16_t extra = (uint16_t)(avail % k); /* spread the remainder, no drift */ + + uint16_t pos = n->dir == SPLIT_COLS ? r.x : r.y; + for (size_t i = 0; i < k; i++) { + uint16_t size = (uint16_t)(each + (i < extra ? 1 : 0)); + rect_t cr = n->dir == SPLIT_COLS + ? (rect_t){.x = pos, .y = r.y, .w = size, .h = r.h} + : (rect_t){.x = r.x, .y = pos, .w = r.w, .h = size}; + layout_node(n->kids[i], cr); + pos = (uint16_t)(pos + size + gap); + } +} + +static void layout(app_t *a) { + uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap; + rect_t r = {.x = gx, + .y = gy, + .w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), + .h = (uint16_t)(a->rows > 2 * gy ? a->rows - 2 * gy : a->rows)}; + if (a->root) layout_node(a->root, r); +} + +void app_write_focused(app_t *a, const void *buf, size_t len) { + if (a->focus) pane_write(a->focus->pane, buf, len); +} + +void app_resize(app_t *a, uint16_t cols, uint16_t rows) { + a->cols = cols; + a->rows = rows; + layout(a); +} + +/* ---- tree edits --------------------------------------------------------- */ + +static void replace_child(node_t *parent, node_t *old, node_t *new_) { + for (size_t i = 0; i < parent->nkids; i++) + if (parent->kids[i] == old) { + parent->kids[i] = new_; + new_->parent = parent; + return; + } +} + +static void split_focus(app_t *a, split_dir_t dir) { + node_t *leaf = a->focus; + if (!leaf) return; + node_t *fresh = leaf_new(a); + if (!fresh) return; + + /* Growing an existing split in the same direction keeps the tree flat, so + * three vertical splits are three equal columns rather than 1/2 + 1/4 + 1/4. */ + if (leaf->parent && leaf->parent->dir == dir) { + node_t *p = leaf->parent; + p->kids = realloc(p->kids, (p->nkids + 1) * sizeof *p->kids); + size_t at = 0; + while (at < p->nkids && p->kids[at] != leaf) at++; + memmove(&p->kids[at + 2], &p->kids[at + 1], + (p->nkids - at - 1) * sizeof *p->kids); + p->kids[at + 1] = fresh; + fresh->parent = p; + p->nkids++; + } else { + node_t *sp = calloc(1, sizeof *sp); + sp->kind = NODE_SPLIT; + sp->dir = dir; + sp->nkids = 2; + sp->kids = malloc(2 * sizeof *sp->kids); + sp->kids[0] = leaf; + sp->kids[1] = fresh; + sp->parent = leaf->parent; + if (leaf->parent) replace_child(leaf->parent, leaf, sp); + else a->root = sp; + leaf->parent = sp; + fresh->parent = sp; + } + + a->focus = fresh; + layout(a); +} + +static node_t *first_leaf(node_t *n) { + while (n && n->kind == NODE_SPLIT) n = n->kids[0]; + return n; +} + +static void close_leaf(app_t *a, node_t *leaf) { + node_t *p = leaf->parent; + if (!p) { /* last pane */ + node_free(leaf); + a->root = NULL; + a->focus = NULL; + a->quit = true; + return; + } + + size_t at = 0; + while (at < p->nkids && p->kids[at] != leaf) at++; + memmove(&p->kids[at], &p->kids[at + 1], + (p->nkids - at - 1) * sizeof *p->kids); + p->nkids--; + node_free(leaf); + + node_t *survivor = p->kids[0]; + if (p->nkids == 1) { /* a split with one child is just that child */ + survivor->parent = p->parent; + if (p->parent) replace_child(p->parent, p, survivor); + else a->root = survivor; + free(p->kids); + free(p); + } + + if (a->focus == leaf || a->focus == p) a->focus = first_leaf(survivor); + layout(a); +} + +struct reap { + app_t *a; + node_t *dead; +}; +static void reap_cb(node_t *n, void *ud) { + struct reap *r = ud; + if (!r->dead && !pane_alive(n->pane)) r->dead = n; +} + +void app_reap(app_t *a) { + for (;;) { + struct reap r = {a, NULL}; + walk(a->root, reap_cb, &r); + if (!r.dead) break; + close_leaf(a, r.dead); + if (!a->root) break; + } +} + +/* ---- focus -------------------------------------------------------------- */ + +struct dirsearch { + node_t *from; + int dx, dy; + node_t *best; + long best_score; +}; + +static void dir_cb(node_t *n, void *ud) { + struct dirsearch *d = ud; + if (n == d->from) return; + /* centres, in a coordinate space where a row counts double so that + * "nearest" means the same thing horizontally and vertically */ + long fx = d->from->rect.x * 2 + d->from->rect.w, fy = d->from->rect.y * 2 + d->from->rect.h; + long nx = n->rect.x * 2 + n->rect.w, ny = n->rect.y * 2 + n->rect.h; + long along = d->dx ? (nx - fx) * d->dx : (ny - fy) * d->dy; + if (along <= 0) return; /* not in that direction */ + long across = d->dx ? labs(ny - fy) : labs(nx - fx); + long score = along + across * 4; /* prefer straight ahead over diagonal */ + if (!d->best || score < d->best_score) { + d->best = n; + d->best_score = score; + } +} + +static void focus_dir(app_t *a, int dx, int dy) { + if (!a->focus) return; + struct dirsearch d = {a->focus, dx, dy, NULL, 0}; + walk(a->root, dir_cb, &d); + if (d.best) a->focus = d.best; +} + +struct nextsearch { + node_t *from; + node_t *first, *prev_match, *next; + bool seen; +}; +static void next_cb(node_t *n, void *ud) { + struct nextsearch *s = ud; + if (!s->first) s->first = n; + if (s->seen && !s->next) s->next = n; + if (n == s->from) s->seen = true; +} + +static void focus_next(app_t *a) { + struct nextsearch s = {a->focus, NULL, NULL, NULL, false}; + walk(a->root, next_cb, &s); + a->focus = s.next ? s.next : s.first; +} + +/* ---- drawing ------------------------------------------------------------ */ + +static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { + rect_t r = leaf->rect; + if (r.w < 3 || r.h < 3) return; + bool focused = leaf == a->focus; + color_t fg = focused ? FRAME_FOCUS : FRAME_IDLE; + uint16_t attrs = 0; + + const char *tl = CFG.rounded ? "╭" : "┌", *tr = CFG.rounded ? "╮" : "┐"; + const char *bl = CFG.rounded ? "╰" : "└", *br = CFG.rounded ? "╯" : "┘"; + + uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); + screen_text(s, r.x, r.y, tl, fg, NO_COLOR, attrs); + screen_text(s, x1, r.y, tr, fg, NO_COLOR, attrs); + screen_text(s, r.x, y1, bl, fg, NO_COLOR, attrs); + screen_text(s, x1, y1, br, fg, NO_COLOR, attrs); + for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { + screen_text(s, x, r.y, "─", fg, NO_COLOR, attrs); + screen_text(s, x, y1, "─", fg, NO_COLOR, attrs); + } + for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { + screen_text(s, r.x, y, "│", fg, NO_COLOR, attrs); + screen_text(s, x1, y, "│", fg, NO_COLOR, attrs); + } + + /* The split button is budgeted BEFORE the title, and hit-tested from the + * same rect it is drawn in. Both halves of that sentence are scar tissue + * from the sl0ppi fork, where a title long enough to fill the frame ate the + * button's columns and the click landed two cells away from the glyph. */ + uint16_t avail = (uint16_t)(r.w - 2); + uint16_t btn_x = 0; + bool has_btn = avail >= 6; + if (has_btn) { + btn_x = (uint16_t)(x1 - 2); + screen_text(s, btn_x, r.y, "+", fg, NO_COLOR, ATTR_BOLD); + char action[48]; + snprintf(action, sizeof action, "split:%u", leaf->id); + hit_add(&s->hits, btn_x, r.y, 1, 1, action); + avail = (uint16_t)(avail - 3); + } + + const char *title = pane_title(leaf->pane); + if (title && *title && avail >= 3) { + char buf[256]; + int len = snprintf(buf, sizeof buf, " %s ", title); + if (len > (int)avail) { + len = (int)avail; + buf[len] = 0; + } + uint16_t tx = (uint16_t)(r.x + 1); + if (CFG.title_align == ALIGN_CENTER) + tx = (uint16_t)(r.x + 1 + (avail - len) / 2); + else if (CFG.title_align == ALIGN_RIGHT) + tx = (uint16_t)(r.x + 1 + avail - len); + screen_text(s, tx, r.y, buf, focused ? TITLE_FOCUS : fg, NO_COLOR, + focused ? ATTR_BOLD : 0); + } +} + +struct draw { + app_t *a; + screen_t *s; +}; + +static void draw_cb(node_t *n, void *ud) { + struct draw *d = ud; + char action[48]; + + /* Painted in order, so the hit list resolves last-painted-wins: the frame + * focuses, the content forwards the mouse, the button splits. */ + snprintf(action, sizeof action, "focus:%u", n->id); + hit_add(&d->s->hits, n->rect.x, n->rect.y, n->rect.w, n->rect.h, action); + + snprintf(action, sizeof action, "pane:%u", n->id); + hit_add(&d->s->hits, n->content.x, n->content.y, n->content.w, n->content.h, + action); + + draw_frame(d->a, d->s, n); + pane_compose(n->pane, d->s, n->content.x, n->content.y, n == d->a->focus); +} + +void app_compose(app_t *a, screen_t *s) { + screen_clear(s); /* every frame starts blank: no ghosts in the gap ring */ + hit_reset(&s->hits); + s->cursor_visible = false; + a->painted = s; + if (!a->root) return; + layout(a); + struct draw d = {a, s}; + walk(a->root, draw_cb, &d); +} + +/* ---- input -------------------------------------------------------------- */ + +struct byid { + uint32_t id; + node_t *found; +}; +static void byid_cb(node_t *n, void *ud) { + struct byid *b = ud; + if (n->id == b->id) b->found = n; +} + +static node_t *by_id(app_t *a, uint32_t id) { + struct byid b = {id, NULL}; + walk(a->root, byid_cb, &b); + return b.found; +} + +static void do_action(app_t *a, const char *action, const input_event_t *ev) { + /* Chrome activates on press. Forwarding to a pane does not: an app wants the + * release and the motion too. Without this, one click of the split button + * splits twice — press and release both landing on the same target. */ + bool to_pane = strncmp(action, "pane:", 5) == 0; + if (!to_pane && ev->maction != MOUSE_PRESS) return; + + uint32_t id = 0; + const char *colon = strchr(action, ':'); + if (colon) id = (uint32_t)strtoul(colon + 1, NULL, 10); + node_t *n = by_id(a, id); + if (!n) return; + + if (strncmp(action, "split:", 6) == 0) { + a->focus = n; + split_focus(a, SPLIT_COLS); + } else if (strncmp(action, "close:", 6) == 0) { + close_leaf(a, n); + } else if (strncmp(action, "pane:", 5) == 0) { + a->focus = n; + /* translate to pane-local coordinates before forwarding */ + input_event_t local = *ev; + local.mx = (uint16_t)(ev->mx - n->content.x); + local.my = (uint16_t)(ev->my - n->content.y); + pane_send_mouse(n->pane, &local); + } else if (strncmp(action, "focus:", 6) == 0) { + a->focus = n; + } +} + +static bool prefix_command(app_t *a, const input_event_t *ev) { + switch (ev->key) { + case GHOSTTY_KEY_Q: a->quit = true; return true; + case GHOSTTY_KEY_BACKSLASH: /* C-a \ or C-a | */ + split_focus(a, SPLIT_COLS); + return true; + case GHOSTTY_KEY_MINUS: + split_focus(a, SPLIT_ROWS); + return true; + case GHOSTTY_KEY_X: + if (a->focus) close_leaf(a, a->focus); + return true; + case GHOSTTY_KEY_O: focus_next(a); return true; + case GHOSTTY_KEY_H: case GHOSTTY_KEY_ARROW_LEFT: focus_dir(a, -1, 0); return true; + case GHOSTTY_KEY_L: case GHOSTTY_KEY_ARROW_RIGHT: focus_dir(a, 1, 0); return true; + case GHOSTTY_KEY_K: case GHOSTTY_KEY_ARROW_UP: focus_dir(a, 0, -1); return true; + case GHOSTTY_KEY_J: case GHOSTTY_KEY_ARROW_DOWN: focus_dir(a, 0, 1); return true; + default: return false; + } +} + +void app_event(app_t *a, const input_event_t *ev) { + if (!a->focus) return; + + if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) { + bool ctrl_a = (ev->mods & MOD_CTRL) && ev->unshifted == 'a'; + if (a->prefix) { + a->prefix = false; + if (ctrl_a) { pane_send_key(a->focus->pane, ev); return; } + prefix_command(a, ev); /* unbound: swallowed */ + return; + } + if (ctrl_a) { + a->prefix = true; + return; + } + } + + switch (ev->kind) { + case EV_KEY: + pane_send_key(a->focus->pane, ev); + break; + case EV_MOUSE: { + /* Mouse routing is a hit-list lookup, never a re-derivation of geometry: + * the list was filled by the pass that painted what the user clicked. */ + if (!a->painted) break; + const char *action = hit_test(&a->painted->hits, ev->mx, ev->my); + if (action) do_action(a, action, ev); + break; + } + case EV_PASTE: + pane_send_paste(a->focus->pane, ev->paste, ev->paste_len); + break; + default: + break; + } +} + +/* ---- introspection ------------------------------------------------------ */ + +struct panes_json { + app_t *a; + json_t *j; +}; +static void panes_cb(node_t *n, void *ud) { + struct panes_json *pj = ud; + json_t *j = pj->j; + json_obj_open(j, NULL); + json_int(j, "id", n->id); + json_int(j, "x", n->rect.x); + json_int(j, "y", n->rect.y); + json_int(j, "w", n->rect.w); + json_int(j, "h", n->rect.h); + json_int(j, "content_x", n->content.x); + json_int(j, "content_y", n->content.y); + json_int(j, "content_w", n->content.w); + json_int(j, "content_h", n->content.h); + json_bool(j, "focused", n == pj->a->focus); + const char *t = pane_title(n->pane); + json_str(j, "title", t ? t : "", t ? strlen(t) : 0); + json_obj_close(j); +} + +char *app_panes_json(app_t *a) { + json_t j; + json_init(&j); + json_arr_open(&j, NULL); + walk(a->root, panes_cb, &(struct panes_json){a, &j}); + json_arr_close(&j); + return j.buf; +} diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..79b7732 --- /dev/null +++ b/src/app.h @@ -0,0 +1,41 @@ +/* The session: a layout tree of panes, focus, and the chrome around them. + * + * Both front ends (the interactive client and the headless driver) drive this + * same object, so there is exactly one implementation of what a key does. */ +#ifndef SL0PTTY_APP_H +#define SL0PTTY_APP_H + +#include "sl0ptty.h" + +typedef struct { + uint16_t x, y, w, h; +} rect_t; + +typedef enum { SPLIT_COLS, SPLIT_ROWS } split_dir_t; + +typedef struct node node_t; +typedef struct app app_t; + +app_t *app_new(const char *const argv[], uint16_t cols, uint16_t rows); +void app_free(app_t *a); + +void app_event(app_t *a, const input_event_t *ev); +void app_resize(app_t *a, uint16_t cols, uint16_t rows); + +/* Pump every pane that has data. Returns true if anything changed. */ +bool app_pump_fd(app_t *a, int fd); +/* Fill `out` with every live pane fd; returns the count. */ +size_t app_fds(app_t *a, int *out, size_t max); +/* Reap panes whose process exited; collapses the tree. */ +void app_reap(app_t *a); + +void app_compose(app_t *a, screen_t *s); +/* Bytes straight into the focused pane's pty (the harness's `raw`). */ +void app_write_focused(app_t *a, const void *buf, size_t len); +bool app_should_quit(const app_t *a); +size_t app_pane_count(const app_t *a); + +/* Layout as JSON, for the harness: id, rect, focus, title. Caller frees. */ +char *app_panes_json(app_t *a); + +#endif /* SL0PTTY_APP_H */ diff --git a/src/headless.c b/src/headless.c index 3da8ee7..c38313f 100644 --- a/src/headless.c +++ b/src/headless.c @@ -26,18 +26,10 @@ #include <time.h> #include <unistd.h> -typedef struct { - pane_t *pane; -} hl_dispatch_t; +#include "app.h" 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; - } + app_event((app_t *)ud, ev); } static int64_t ms_now(void) { @@ -76,37 +68,49 @@ static size_t unescape(const char *in, uint8_t *out, size_t cap) { return n; } -/* Pump the pane until it has produced nothing for `quiet` ms. */ -static void settle(pane_t *p, int quiet) { +/* Pump every pane until none has produced anything for `quiet` ms. */ +static void settle(app_t *a, 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; + while (!app_should_quit(a) && ms_now() < deadline) { + int fds[64]; + size_t n = app_fds(a, fds, 64); + if (n == 0) break; + struct pollfd pfds[64]; + for (size_t i = 0; i < n; i++) { + pfds[i].fd = fds[i]; + pfds[i].events = POLLIN; + pfds[i].revents = 0; + } + int r = poll(pfds, (nfds_t)n, 10); + if (r > 0) { + for (size_t i = 0; i < n; i++) + if (pfds[i].revents) { + app_pump_fd(a, pfds[i].fd); + last = ms_now(); + } } + app_reap(a); 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) { + app_t *a = app_new(argv, cols, rows); + if (!a) { fprintf(stderr, "sl0ptty: cannot spawn pane\n"); return 1; } + app_resize(a, cols, rows); 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); + settle(a, idle_ms); + app_compose(a, &s); char *dump = screen_dump(&s); fputs(dump, stdout); free(dump); @@ -123,31 +127,38 @@ int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, 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); + input_feed(in, buf, n, hl_event, a); + if (input_pending(in)) input_timeout(in, hl_event, a); } else if (strcmp(line, "raw") == 0) { uint8_t buf[4096]; size_t n = unescape(arg, buf, sizeof buf); - pane_write(p, buf, n); + app_write_focused(a, buf, n); } else if (strcmp(line, "settle") == 0) { - settle(p, *arg ? atoi(arg) : idle_ms); + settle(a, *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); + app_resize(a, cols, rows); } else if (strcmp(line, "snapshot") == 0) { - pane_compose(p, &s, 0, 0, true); + app_compose(a, &s); 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, "panes") == 0) { + app_compose(a, &s); /* layout is a function of the frame; compose first */ + char *dump = app_panes_json(a); + fputs(dump, stdout); + fputs("\n", stdout); + free(dump); + fflush(stdout); } else if (strcmp(line, "alive") == 0) { - printf("%s\n", pane_alive(p) ? "true" : "false"); + printf("%s\n", app_should_quit(a) ? "false" : "true"); fflush(stdout); } else if (strcmp(line, "quit") == 0) { break; @@ -159,6 +170,6 @@ int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, done: input_free(in); screen_free(&s); - pane_free(p); + app_free(a); return 0; } @@ -3,6 +3,7 @@ */ #define _GNU_SOURCE #include "sl0ptty.h" +#include "app.h" #include <ghostty/vt.h> @@ -18,6 +19,7 @@ #include <time.h> #include <unistd.h> +#define MAX_PANES 64 /* a screen cannot usefully hold more */ #define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */ #define ESC_MS 40 /* how long a bare ESC waits to become a sequence */ @@ -87,39 +89,10 @@ int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, /* ---- interactive -------------------------------------------------------- */ -/* Where decoded events go. The prefix (C-a) is handled here, on semantic - * events, so it cannot be confused by a byte that merely looks like C-a in the - * middle of a UTF-8 sequence or an escape. */ -typedef struct { - pane_t *pane; - bool prefix; - volatile sig_atomic_t *quit; -} dispatch_t; - +/* Every decoded event goes to the app, which owns the prefix, the layout and + * what a key means. The client is only a decoder and a screen. */ static void on_event(const input_event_t *ev, void *ud) { - dispatch_t *d = ud; - - if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) { - bool ctrl_a = (ev->mods & MOD_CTRL) && ev->unshifted == 'a'; - if (d->prefix) { - d->prefix = false; - if (ev->key == GHOSTTY_KEY_Q) { *d->quit = 1; return; } - if (ctrl_a) { pane_send_key(d->pane, ev); return; } /* C-a C-a = literal */ - return; /* unbound: swallow */ - } - if (ctrl_a) { - d->prefix = true; - return; - } - } - - 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; - case EV_FOCUS: break; /* M1: focus follows the layout, not the client */ - default: break; - } + app_event((app_t *)ud, ev); } static int run_interactive(const char *const argv[]) { @@ -131,23 +104,23 @@ static int run_interactive(const char *const argv[]) { return 1; } - pane_t *p = pane_new(argv, cols, rows, NULL); - if (!p) { + app_t *a = app_new(argv, cols, rows); + if (!a) { fprintf(stderr, "sl0ptty: cannot spawn pane\n"); return 1; } + app_resize(a, cols, rows); screen_t s; screen_init(&s, cols, rows); input_parser_t *in = input_new(); - dispatch_t d = {.pane = p, .quit = &g_quit}; bool pending_paint = true; int64_t next_frame = 0; int64_t esc_due = 0; - while (!g_quit && pane_alive(p)) { + while (!g_quit && !app_should_quit(a)) { int timeout = -1; if (pending_paint) { int64_t due = next_frame - now_ms(); @@ -159,17 +132,19 @@ static int run_interactive(const char *const argv[]) { if (timeout < 0 || t < timeout) timeout = t; } - struct pollfd pfds[2] = { - {.fd = STDIN_FILENO, .events = POLLIN}, - {.fd = pane_fd(p), .events = POLLIN}, - }; - int n = poll(pfds, 2, timeout); + int fds[MAX_PANES]; + size_t npanes = app_fds(a, fds, MAX_PANES); + struct pollfd pfds[MAX_PANES + 1]; + pfds[0] = (struct pollfd){.fd = STDIN_FILENO, .events = POLLIN}; + for (size_t i = 0; i < npanes; i++) + pfds[i + 1] = (struct pollfd){.fd = fds[i], .events = POLLIN}; + int n = poll(pfds, (nfds_t)(npanes + 1), timeout); if (g_winch) { g_winch = 0; term_size(&cols, &rows); screen_resize(&s, cols, rows); - pane_resize(p, cols, rows); + app_resize(a, cols, rows); pending_paint = true; next_frame = now_ms(); } @@ -180,32 +155,38 @@ static int run_interactive(const char *const argv[]) { uint8_t buf[8192]; ssize_t r = read(STDIN_FILENO, buf, sizeof buf); if (r > 0) { - input_feed(in, buf, (size_t)r, on_event, &d); + input_feed(in, buf, (size_t)r, on_event, a); esc_due = now_ms() + ESC_MS; + pending_paint = true; /* focus, splits and closes are visible changes */ + if (!next_frame) next_frame = now_ms(); } } /* A lone ESC is only the Escape key once nothing follows it. */ - if (input_pending(in) && now_ms() >= esc_due) input_timeout(in, on_event, &d); + if (input_pending(in) && now_ms() >= esc_due) input_timeout(in, on_event, a); - if (n > 0 && (pfds[1].revents & (POLLIN | POLLHUP))) { - pane_pump(p); - if (!pending_paint) { - pending_paint = true; - next_frame = now_ms() + FRAME_MS; + for (size_t i = 0; i < npanes; i++) { + if (n > 0 && (pfds[i + 1].revents & (POLLIN | POLLHUP))) { + app_pump_fd(a, pfds[i + 1].fd); + if (!pending_paint) { + pending_paint = true; + next_frame = now_ms() + FRAME_MS; + } } } + app_reap(a); if (pending_paint && now_ms() >= next_frame) { - pane_compose(p, &s, 0, 0, true); + app_compose(a, &s); screen_flush(&s, STDOUT_FILENO); pending_paint = false; + next_frame = 0; } } input_free(in); screen_free(&s); - pane_free(p); + app_free(a); restore_tty(); return 0; } @@ -71,12 +71,17 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, if (ghostty_mouse_encoder_new(NULL, &p->menc) != GHOSTTY_SUCCESS) goto fail; if (ghostty_mouse_event_new(NULL, &p->mev) != 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); + /* Careful: for pointer-typed options the header says "Input type: + * GhosttyTerminalWritePtyFn", not "...Fn *" — the value pointer IS the + * value. Passing &fn stores the address of a stack local as the callback, + * which survives exactly until pane_new() returns and then jumps into a + * dead frame. Struct-typed options (MODE) do take a pointer; the rule is + * that a pointer-shaped value is passed as itself. */ + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_USERDATA, p); + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + (const void *)(uintptr_t)on_write_pty); + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, + (const void *)(uintptr_t)on_title_changed); /* 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. diff --git a/src/screen.c b/src/screen.c index 9d31640..e36f461 100644 --- a/src/screen.c +++ b/src/screen.c @@ -119,13 +119,36 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt, cell_t *c = screen_at(s, x, y); if (!c) return; if (len > sizeof c->text) len = sizeof c->text; + memset(c->text, 0, sizeof c->text); memcpy(c->text, txt, len); c->len = (uint8_t)len; + c->width = 1; c->fg = fg; c->bg = bg; c->attrs = attrs; } +static size_t u8_len(unsigned char b) { + if (b < 0x80) return 1; + if ((b & 0xe0) == 0xc0) return 2; + if ((b & 0xf0) == 0xe0) return 3; + if ((b & 0xf8) == 0xf0) return 4; + return 1; +} + +uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt, + color_t fg, color_t bg, uint16_t attrs) { + uint16_t n = 0; + for (const char *p = txt; *p;) { + size_t len = u8_len((unsigned char)*p); + if (x + n >= s->cols) break; + screen_put_utf8(s, (uint16_t)(x + n), y, p, len, fg, bg, attrs); + p += len; + n++; + } + return n; /* cells written, so a caller can lay out what follows */ +} + static bool color_eq(color_t a, color_t b) { if (a.set != b.set) return false; if (!a.set) return true; @@ -161,11 +184,16 @@ static bool style_eq(const cell_t *a, const cell_t *b) { void screen_flush(screen_t *s, int fd) { s->out_len = 0; - out_str(s, "\x1b[?25l"); /* hide cursor while painting */ + + /* The cursor is hidden lazily, on the first cell we actually repaint, so a + * frame with no changes emits nothing at all rather than a hide/show pair. + * A keystroke that changes nothing should cost zero bytes. */ + bool painted = false; if (s->force_full) { - out_str(s, "\x1b[0m\x1b[H\x1b[2J"); + out_str(s, "\x1b[?25l\x1b[0m\x1b[H\x1b[2J"); memset(s->prev, 0, (size_t)s->cols * s->rows * sizeof(cell_t)); + painted = true; } bool have_pos = false, have_style = false; @@ -179,6 +207,10 @@ void screen_flush(screen_t *s, int fd) { if (c->width == 0) continue; /* tail of a wide cell: never painted */ if (cell_eq(c, p)) continue; + if (!painted) { + out_str(s, "\x1b[?25l"); + painted = true; + } if (!have_pos || cx != x || cy != y) { out_fmt(s, "\x1b[%u;%uH", y + 1, x + 1); cx = x; @@ -199,9 +231,19 @@ void screen_flush(screen_t *s, int fd) { } } - 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"); + bool cursor_moved = s->cursor_visible != s->shown_cursor_visible || + s->cursor_x != s->shown_cursor_x || + s->cursor_y != s->shown_cursor_y; + if (painted || cursor_moved) { + 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"); + } else if (painted) { + out_str(s, "\x1b[?25l"); + } + s->shown_cursor_visible = s->cursor_visible; + s->shown_cursor_x = s->cursor_x; + s->shown_cursor_y = s->cursor_y; } s->force_full = false; diff --git a/src/sl0ptty.h b/src/sl0ptty.h index 962de66..fdba027 100644 --- a/src/sl0ptty.h +++ b/src/sl0ptty.h @@ -67,6 +67,9 @@ typedef struct { /* where the real cursor goes after a flush */ bool cursor_visible; uint16_t cursor_x, cursor_y; + /* what the terminal was last told, so an unchanged frame emits nothing */ + bool shown_cursor_visible; + uint16_t shown_cursor_x, shown_cursor_y; /* output accumulator, flushed in one write() */ char *out; @@ -80,6 +83,9 @@ 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); +/* Write a UTF-8 string one cell per codepoint; returns cells written. */ +uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt, + 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) */ |
