diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
| commit | 7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40 (patch) | |
| tree | 6aaced8bd6927565816ce48ae07fb5f397d6edf7 /src | |
| parent | sl0ptty: M2 — server/client split, detach/reattach, control socket (diff) | |
| download | slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.gz slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.xz slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.zip | |
sl0ptty: M3 — tabs, purposes, and the JSON control API
Tabs are layout trees, one per tab, and panes in every tab keep running: fds,
pumping and reaping span all of them, only the current one is composed. A
one-row strip shows them (M5 makes it the real status bar) and each label
registers a hit, so clicking a tab switches to it through the same mechanism
as every other click.
C-a c new tab C-a n / C-a p cycle C-a 1..9 select
Purposes on panes and tabs with D8's trust model implemented as a lock:
declared (from a layout or an operator) outranks in-band, cannot be overridden,
and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo
bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives
with the OSC scanner in M4 and always passes declared:false; the refusal is
already tested.
The control API is now JSON in, JSON out:
{"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"}
{"ok":true,"id":2}
with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/
set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the
focused one', so a background tab can be scripted. The bare-verb form stays as
a harness alias and runs the same code, so the tests cannot drift from the API.
Adds a small JSON reader (jsonval.c) — we could only emit until now.
129 checks green.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 357 | ||||
| -rw-r--r-- | src/app.h | 26 | ||||
| -rw-r--r-- | src/cmd.c | 165 | ||||
| -rw-r--r-- | src/jsonval.c | 261 | ||||
| -rw-r--r-- | src/jsonval.h | 40 |
5 files changed, 792 insertions, 57 deletions
@@ -40,6 +40,8 @@ struct node { pane_t *pane; uint32_t id; rect_t content; /* where the pane's cells go */ + char purpose[64]; + bool purpose_locked; /* declared by a layout: in-band cannot override */ /* split */ split_dir_t dir; @@ -47,9 +49,21 @@ struct node { size_t nkids; }; -struct app { +/* A tab is a layout tree and its focus. Panes in every tab keep running; only + * the current tab is composed. */ +typedef struct { node_t *root; node_t *focus; + uint32_t id; + char name[64]; + char purpose[64]; + bool purpose_locked; +} tab_t; + +struct app { + tab_t *tabs; + size_t ntabs, tabcap, cur; + uint32_t next_tab_id; uint32_t next_id; uint16_t cols, rows; bool prefix; @@ -61,6 +75,8 @@ struct app { const screen_t *painted; }; +static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; } + static node_t *leaf_new(app_t *a) { pane_t *p = pane_new(a->argv, 1, 1, NULL); if (!p) return NULL; @@ -82,27 +98,58 @@ static void node_free(node_t *n) { free(n); } +/* Purposes are how tooling finds "the agent pane" (D8). Sanitised on ingest to + * the charset sl0ppi settled on, so the format survives verbatim. */ +static void sanitise_purpose(const char *in, char *out, size_t cap) { + size_t n = 0; + for (const char *p = in; *p && n + 1 < cap; p++) { + char c = *p; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' || + c == '/' || c == '-'; + if (ok) out[n++] = c; + } + out[n] = 0; +} + +static tab_t *tab_add(app_t *a, const char *name) { + if (a->ntabs == a->tabcap) { + a->tabcap = a->tabcap ? a->tabcap * 2 : 4; + a->tabs = realloc(a->tabs, a->tabcap * sizeof *a->tabs); + } + tab_t *t = &a->tabs[a->ntabs++]; + memset(t, 0, sizeof *t); + t->id = ++a->next_tab_id; + snprintf(t->name, sizeof t->name, "%s", name && *name ? name : ""); + return t; +} + 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) { + tab_add(a, ""); + a->cur = 0; + node_t *leaf = leaf_new(a); + if (!leaf) { + free(a->tabs); free(a); return NULL; } - a->focus = a->root; + cur(a)->root = leaf; + cur(a)->focus = leaf; return a; } void app_free(app_t *a) { if (!a) return; - node_free(a->root); + for (size_t i = 0; i < a->ntabs; i++) node_free(a->tabs[i].root); + free(a->tabs); free(a); } -bool app_should_quit(const app_t *a) { return a->quit || a->root == NULL; } +bool app_should_quit(const app_t *a) { return a->quit || a->ntabs == 0; } bool app_detach_requested(const app_t *a) { return a->detach; } void app_clear_detach(app_t *a) { a->detach = false; } @@ -119,11 +166,32 @@ static void walk(node_t *n, leaf_fn fn, void *ud) { for (size_t i = 0; i < n->nkids; i++) walk(n->kids[i], fn, ud); } +static void walk_all(app_t *a, leaf_fn fn, void *ud) { + for (size_t i = 0; i < a->ntabs; i++) walk(a->tabs[i].root, fn, ud); +} + +/* Which tab a node lives in: climb to its root and match. */ +static size_t tab_of(app_t *a, node_t *n) { + while (n->parent) n = n->parent; + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].root == n) return i; + return (size_t)-1; +} + +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 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); + walk_all((app_t *)a, count_cb, &n); return n; } @@ -147,13 +215,13 @@ static void collect_cb(node_t *n, void *ud) { size_t app_fds(app_t *a, int *out, size_t max) { struct collect c = {out, 0, max}; - walk(a->root, collect_cb, &c); + walk_all(a, collect_cb, &c); /* background tabs keep running */ 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); + walk_all(a, find_fd_cb, &f); if (!f.found) return false; pane_pump(f.found->pane); return true; @@ -198,17 +266,21 @@ static void layout_node(node_t *n, rect_t r) { } } +#define STRIP_ROWS 1 /* M5 turns this into the real status bar */ + static void layout(app_t *a) { + if (!a->ntabs) return; uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap; + uint16_t top = (uint16_t)(gy + STRIP_ROWS); rect_t r = {.x = gx, - .y = gy, + .y = top, .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); + .h = (uint16_t)(a->rows > top + gy ? a->rows - top - gy : 1)}; + if (cur(a)->root) layout_node(cur(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); + if (cur(a)->focus) pane_write(cur(a)->focus->pane, buf, len); } void app_resize(app_t *a, uint16_t cols, uint16_t rows) { @@ -229,7 +301,7 @@ static void replace_child(node_t *parent, node_t *old, node_t *new_) { } static void split_focus(app_t *a, split_dir_t dir) { - node_t *leaf = a->focus; + node_t *leaf = cur(a)->focus; if (!leaf) return; node_t *fresh = leaf_new(a); if (!fresh) return; @@ -256,12 +328,12 @@ static void split_focus(app_t *a, split_dir_t dir) { sp->kids[1] = fresh; sp->parent = leaf->parent; if (leaf->parent) replace_child(leaf->parent, leaf, sp); - else a->root = sp; + else cur(a)->root = sp; leaf->parent = sp; fresh->parent = sp; } - a->focus = fresh; + cur(a)->focus = fresh; layout(a); } @@ -270,13 +342,25 @@ static node_t *first_leaf(node_t *n) { return n; } +static void tab_remove(app_t *a, size_t ti) { + node_free(a->tabs[ti].root); + memmove(&a->tabs[ti], &a->tabs[ti + 1], + (a->ntabs - ti - 1) * sizeof *a->tabs); + a->ntabs--; + if (a->cur >= a->ntabs && a->ntabs) a->cur = a->ntabs - 1; +} + static void close_leaf(app_t *a, node_t *leaf) { + size_t ti = tab_of(a, leaf); + if (ti == (size_t)-1) return; + tab_t *t = &a->tabs[ti]; node_t *p = leaf->parent; - if (!p) { /* last pane */ + + if (!p) { /* the tab's last pane: the tab goes with it */ + t->root = NULL; node_free(leaf); - a->root = NULL; - a->focus = NULL; - a->quit = true; + tab_remove(a, ti); + if (a->ntabs == 0) a->quit = true; return; } @@ -291,12 +375,12 @@ static void close_leaf(app_t *a, node_t *leaf) { 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; + else t->root = survivor; free(p->kids); free(p); } - if (a->focus == leaf || a->focus == p) a->focus = first_leaf(survivor); + if (t->focus == leaf || t->focus == p) t->focus = first_leaf(survivor); layout(a); } @@ -312,13 +396,123 @@ static void reap_cb(node_t *n, void *ud) { void app_reap(app_t *a) { for (;;) { struct reap r = {a, NULL}; - walk(a->root, reap_cb, &r); + walk_all(a, reap_cb, &r); if (!r.dead) break; close_leaf(a, r.dead); - if (!a->root) break; + if (a->ntabs == 0) break; + } +} + +uint32_t app_new_tab(app_t *a, const char *name) { + tab_t *t = tab_add(a, name); + node_t *leaf = leaf_new(a); + if (!leaf) { + a->ntabs--; + return 0; } + t->root = leaf; + t->focus = leaf; + a->cur = a->ntabs - 1; + layout(a); + return t->id; +} + +bool app_select_tab(app_t *a, size_t index) { + if (index >= a->ntabs) return false; + a->cur = index; + layout(a); + return true; +} + +bool app_select_tab_id(app_t *a, uint32_t id) { + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].id == id) return app_select_tab(a, i); + return false; +} + +void app_cycle_tab(app_t *a, int delta) { + if (a->ntabs < 2) return; + long n = (long)a->ntabs; + a->cur = (size_t)(((long)a->cur + delta % n + n) % n); + layout(a); } +size_t app_tab_count(const app_t *a) { return a->ntabs; } + +/* D8's trust model: a purpose declared by a layout outranks an in-band one and + * cannot be overridden, so `cat hostile.txt` in a pane cannot relabel a + * project tab. `declared` is only ever true on the control path. */ +bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose, + bool declared) { + struct byid b = {id, NULL}; + walk_all(a, byid_cb, &b); + if (!b.found) return false; + if (b.found->purpose_locked && !declared) return false; + sanitise_purpose(purpose, b.found->purpose, sizeof b.found->purpose); + if (declared) b.found->purpose_locked = true; + return true; +} + +bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose, + bool declared) { + for (size_t i = 0; i < a->ntabs; i++) { + tab_t *t = &a->tabs[i]; + if (t->id != id) continue; + if (t->purpose_locked && !declared) return false; + sanitise_purpose(purpose, t->purpose, sizeof t->purpose); + if (declared) t->purpose_locked = true; + return true; + } + return false; +} + +bool app_set_tab_name(app_t *a, uint32_t id, const char *name) { + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].id == id) { + snprintf(a->tabs[i].name, sizeof a->tabs[i].name, "%s", name); + return true; + } + return false; +} + +/* Control-API verbs that address a pane by id, rather than "the focused one". + * They select the pane's tab first, so scripting a background tab works. */ +static node_t *pane_by_id(app_t *a, uint32_t id) { + struct byid b = {id, NULL}; + walk_all(a, byid_cb, &b); + return b.found; +} + +bool app_focus_pane(app_t *a, uint32_t id) { + node_t *n = pane_by_id(a, id); + if (!n) return false; + size_t ti = tab_of(a, n); + if (ti == (size_t)-1) return false; + a->cur = ti; + a->tabs[ti].focus = n; + layout(a); + return true; +} + +bool app_split_pane(app_t *a, uint32_t id, bool rows) { + if (id && !app_focus_pane(a, id)) return false; + split_focus(a, rows ? SPLIT_ROWS : SPLIT_COLS); + return true; +} + +bool app_close_pane(app_t *a, uint32_t id) { + node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; + if (!n) return false; + close_leaf(a, n); + return true; +} + +uint32_t app_focused_pane_id(app_t *a) { + return a->ntabs && cur(a)->focus ? cur(a)->focus->id : 0; +} + +uint32_t app_current_tab_id(app_t *a) { return a->ntabs ? cur(a)->id : 0; } + /* ---- focus -------------------------------------------------------------- */ struct dirsearch { @@ -346,10 +540,10 @@ static void dir_cb(node_t *n, void *ud) { } 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; + if (!cur(a)->focus) return; + struct dirsearch d = {cur(a)->focus, dx, dy, NULL, 0}; + walk(cur(a)->root, dir_cb, &d); + if (d.best) cur(a)->focus = d.best; } struct nextsearch { @@ -365,9 +559,9 @@ static void next_cb(node_t *n, void *ud) { } 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; + struct nextsearch s = {cur(a)->focus, NULL, NULL, NULL, false}; + walk(cur(a)->root, next_cb, &s); + cur(a)->focus = s.next ? s.next : s.first; } /* ---- drawing ------------------------------------------------------------ */ @@ -375,7 +569,7 @@ static void focus_next(app_t *a) { 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; + bool focused = leaf == cur(a)->focus; color_t fg = focused ? FRAME_FOCUS : FRAME_IDLE; uint16_t attrs = 0; @@ -449,7 +643,27 @@ static void draw_cb(node_t *n, void *ud) { action); draw_frame(d->a, d->s, n); - pane_compose(n->pane, d->s, n->content.x, n->content.y, n == d->a->focus); + pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus); +} + +static void draw_tab_strip(app_t *a, screen_t *s) { + uint16_t x = (uint16_t)(CFG.gap * CFG.gap_aspect); + for (size_t i = 0; i < a->ntabs && x < s->cols; i++) { + tab_t *t = &a->tabs[i]; + char label[80]; + const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : ""); + if (nm[0]) snprintf(label, sizeof label, " %zu:%s ", i + 1, nm); + else snprintf(label, sizeof label, " %zu ", i + 1); + + bool active = i == a->cur; + uint16_t w = screen_text(s, x, CFG.gap, label, + active ? TITLE_FOCUS : FRAME_IDLE, NO_COLOR, + active ? ATTR_BOLD : 0); + char action[48]; + snprintf(action, sizeof action, "tab:%u", t->id); + hit_add(&s->hits, x, CFG.gap, w, 1, action); + x = (uint16_t)(x + w); + } } void app_compose(app_t *a, screen_t *s) { @@ -457,30 +671,27 @@ void app_compose(app_t *a, screen_t *s) { hit_reset(&s->hits); s->cursor_visible = false; a->painted = s; - if (!a->root) return; + if (!a->ntabs || !cur(a)->root) return; layout(a); + draw_tab_strip(a, s); struct draw d = {a, s}; - walk(a->root, draw_cb, &d); + walk(cur(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); + walk(cur(a)->root, byid_cb, &b); return b.found; } static void do_action(app_t *a, const char *action, const input_event_t *ev) { + if (strncmp(action, "tab:", 4) == 0) { + if (ev->maction == MOUSE_PRESS) + app_select_tab_id(a, (uint32_t)strtoul(action + 4, NULL, 10)); + return; + } /* 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. */ @@ -494,19 +705,19 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { if (!n) return; if (strncmp(action, "split:", 6) == 0) { - a->focus = n; + cur(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; + cur(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; + cur(a)->focus = n; } } @@ -521,25 +732,33 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { split_focus(a, SPLIT_ROWS); return true; case GHOSTTY_KEY_X: - if (a->focus) close_leaf(a, a->focus); + if (cur(a)->focus) close_leaf(a, cur(a)->focus); return true; case GHOSTTY_KEY_O: focus_next(a); return true; + case GHOSTTY_KEY_C: app_new_tab(a, ""); return true; + case GHOSTTY_KEY_N: app_cycle_tab(a, 1); return true; + case GHOSTTY_KEY_P: app_cycle_tab(a, -1); 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; + default: + if (ev->key >= GHOSTTY_KEY_DIGIT_1 && ev->key <= GHOSTTY_KEY_DIGIT_9) { + app_select_tab(a, (size_t)(ev->key - GHOSTTY_KEY_DIGIT_1)); + return true; + } + return false; } } void app_event(app_t *a, const input_event_t *ev) { - if (!a->focus) return; + if (!cur(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; } + if (ctrl_a) { pane_send_key(cur(a)->focus->pane, ev); return; } prefix_command(a, ev); /* unbound: swallowed */ return; } @@ -551,7 +770,7 @@ void app_event(app_t *a, const input_event_t *ev) { switch (ev->kind) { case EV_KEY: - pane_send_key(a->focus->pane, ev); + pane_send_key(cur(a)->focus->pane, ev); break; case EV_MOUSE: { /* Mouse routing is a hit-list lookup, never a re-derivation of geometry: @@ -562,7 +781,7 @@ void app_event(app_t *a, const input_event_t *ev) { break; } case EV_PASTE: - pane_send_paste(a->focus->pane, ev->paste, ev->paste_len); + pane_send_paste(cur(a)->focus->pane, ev->paste, ev->paste_len); break; default: break; @@ -588,7 +807,10 @@ static void panes_cb(node_t *n, void *ud) { 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); + json_bool(j, "focused", n == cur(pj->a)->focus); + json_str(j, "purpose", n->purpose, strlen(n->purpose)); + json_bool(j, "purpose_declared", n->purpose_locked); + json_int(j, "tab", (long long)tab_of(pj->a, n) + 1); const char *t = pane_title(n->pane); json_str(j, "title", t ? t : "", t ? strlen(t) : 0); json_obj_close(j); @@ -598,7 +820,30 @@ 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}); + struct panes_json pj = {a, &j}; + walk_all(a, panes_cb, &pj); /* every tab, so tooling can find any pane */ + json_arr_close(&j); + return j.buf; +} + +char *app_tabs_json(app_t *a) { + json_t j; + json_init(&j); + json_arr_open(&j, NULL); + for (size_t i = 0; i < a->ntabs; i++) { + tab_t *t = &a->tabs[i]; + size_t panes = 0; + walk(t->root, count_cb, &panes); + json_obj_open(&j, NULL); + json_int(&j, "id", t->id); + json_int(&j, "index", (long long)i + 1); + json_str(&j, "name", t->name, strlen(t->name)); + json_str(&j, "purpose", t->purpose, strlen(t->purpose)); + json_bool(&j, "purpose_declared", t->purpose_locked); + json_bool(&j, "active", i == a->cur); + json_int(&j, "panes", (long long)panes); + json_obj_close(&j); + } json_arr_close(&j); return j.buf; } @@ -38,7 +38,31 @@ bool app_detach_requested(const app_t *a); void app_clear_detach(app_t *a); size_t app_pane_count(const app_t *a); -/* Layout as JSON, for the harness: id, rect, focus, title. Caller frees. */ +/* Tabs. A tab is a layout tree; panes in every tab keep running. */ +uint32_t app_new_tab(app_t *a, const char *name); +bool app_select_tab(app_t *a, size_t index); +bool app_select_tab_id(app_t *a, uint32_t id); +void app_cycle_tab(app_t *a, int delta); +size_t app_tab_count(const app_t *a); +bool app_set_tab_name(app_t *a, uint32_t id, const char *name); + +/* Purposes (D8). `declared` means "from a layout/control API": it outranks an + * in-band purpose and locks it, so a pane cannot relabel itself afterwards. + * Returns false when a locked purpose refuses an in-band change. */ +bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose, + bool declared); +bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose, + bool declared); + +/* Address panes by id (the control API), not "the focused one". */ +bool app_focus_pane(app_t *a, uint32_t id); +bool app_split_pane(app_t *a, uint32_t id, bool rows); +bool app_close_pane(app_t *a, uint32_t id); +uint32_t app_focused_pane_id(app_t *a); +uint32_t app_current_tab_id(app_t *a); + +/* State as JSON, for the control API and the harness. Caller frees. */ char *app_panes_json(app_t *a); +char *app_tabs_json(app_t *a); #endif /* SL0PTTY_APP_H */ @@ -8,6 +8,9 @@ #include <stdlib.h> #include <string.h> +#include "json.h" +#include "jsonval.h" + /* \e \n \r \t \\ \0 \xHH -> bytes */ size_t cmd_unescape(const char *in, uint8_t *out, size_t cap) { size_t n = 0; @@ -42,8 +45,166 @@ static void feed_event(const input_event_t *ev, void *ud) { app_event((app_t *)ud, ev); } +/* ---- the JSON control API (D3) ------------------------------------------ + * + * Requests are one JSON object per line, `{"cmd": ...}`; replies are one JSON + * object. The bare-verb form below is kept as a human/harness alias, and both + * run the same code, so a script cannot drift from what the API does. + */ + +static char *jerr(const char *msg) { + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_bool(&j, "ok", false); + json_str(&j, "error", msg, strlen(msg)); + json_obj_close(&j); + return j.buf; +} + +static char *jok_int(const char *key, long long val) { + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_bool(&j, "ok", true); + if (key) json_int(&j, key, val); + json_obj_close(&j); + return j.buf; +} + +/* {"ok":true,"<key>":<already-serialised JSON>} */ +static char *jok_raw(const char *key, char *raw) { + size_t n = strlen(raw) + strlen(key) + 32; + char *out = malloc(n); + snprintf(out, n, "{\"ok\":true,\"%s\":%s}", key, raw); + free(raw); + return out; +} + +static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, + const jv_t *req, bool *quit) { + const char *cmd = jv_gets(req, "cmd", NULL); + if (!cmd) return jerr("missing cmd"); + + if (strcmp(cmd, "panes") == 0) { + app_compose(a, s); + return jok_raw("panes", app_panes_json(a)); + } + if (strcmp(cmd, "tabs") == 0) { + app_compose(a, s); + return jok_raw("tabs", app_tabs_json(a)); + } + if (strcmp(cmd, "snapshot") == 0) { + app_compose(a, s); + const char *fmt = jv_gets(req, "format", "json"); + if (strcmp(fmt, "text") == 0) { + char *txt = screen_dump(s); + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_bool(&j, "ok", true); + json_str(&j, "text", txt, strlen(txt)); + json_obj_close(&j); + free(txt); + return j.buf; + } + return jok_raw("screen", screen_dump_json(s)); + } + if (strcmp(cmd, "send") == 0) { + const char *data = jv_gets(req, "data", ""); + input_feed(in, (const uint8_t *)data, strlen(data), feed_event, a); + if (input_pending(in)) input_timeout(in, feed_event, a); + return jok_int(NULL, 0); + } + if (strcmp(cmd, "raw") == 0) { + const char *data = jv_gets(req, "data", ""); + app_write_focused(a, data, strlen(data)); + return jok_int(NULL, 0); + } + if (strcmp(cmd, "resize") == 0) { + uint16_t c = (uint16_t)jv_geti(req, "cols", s->cols); + uint16_t r = (uint16_t)jv_geti(req, "rows", s->rows); + if (!c || !r) return jerr("bad size"); + screen_resize(s, c, r); + app_resize(a, c, r); + return jok_int(NULL, 0); + } + if (strcmp(cmd, "split") == 0) { + bool rows = strcmp(jv_gets(req, "dir", "cols"), "rows") == 0; + if (!app_split_pane(a, (uint32_t)jv_geti(req, "id", 0), rows)) + return jerr("no such pane"); + return jok_int("id", app_focused_pane_id(a)); + } + if (strcmp(cmd, "focus") == 0) { + if (!app_focus_pane(a, (uint32_t)jv_geti(req, "id", 0))) + return jerr("no such pane"); + return jok_int("id", app_focused_pane_id(a)); + } + if (strcmp(cmd, "close") == 0) { + if (!app_close_pane(a, (uint32_t)jv_geti(req, "id", 0))) + return jerr("no such pane"); + return jok_int(NULL, 0); + } + if (strcmp(cmd, "new-tab") == 0) { + uint32_t id = app_new_tab(a, jv_gets(req, "name", "")); + if (!id) return jerr("cannot create tab"); + const char *purpose = jv_gets(req, "purpose", NULL); + if (purpose) app_set_tab_purpose(a, id, purpose, true); + return jok_int("id", id); + } + if (strcmp(cmd, "select-tab") == 0) { + long id = jv_geti(req, "id", 0); + bool ok = id ? app_select_tab_id(a, (uint32_t)id) + : app_select_tab(a, (size_t)(jv_geti(req, "index", 1) - 1)); + return ok ? jok_int("id", app_current_tab_id(a)) : jerr("no such tab"); + } + if (strcmp(cmd, "set-name") == 0) { + return app_set_tab_name(a, (uint32_t)jv_geti(req, "id", 0), + jv_gets(req, "name", "")) + ? jok_int(NULL, 0) + : jerr("no such tab"); + } + if (strcmp(cmd, "set-purpose") == 0) { + const char *target = jv_gets(req, "target", "pane"); + uint32_t id = (uint32_t)jv_geti(req, "id", 0); + const char *purpose = jv_gets(req, "purpose", ""); + /* Declared purposes come from a layout or an operator, never from a + * pane's own output; the in-band path (M4) always passes false. */ + bool declared = jv_getb(req, "declared", true); + bool ok = strcmp(target, "tab") == 0 + ? app_set_tab_purpose(a, id, purpose, declared) + : app_set_pane_purpose(a, id, purpose, declared); + return ok ? jok_int(NULL, 0) : jerr("refused"); + } + if (strcmp(cmd, "alive") == 0) { + json_t j; + json_init(&j); + json_obj_open(&j, NULL); + json_bool(&j, "ok", true); + json_bool(&j, "alive", !app_should_quit(a)); + json_int(&j, "panes", (long long)app_pane_count(a)); + json_int(&j, "tabs", (long long)app_tab_count(a)); + json_obj_close(&j); + return j.buf; + } + if (strcmp(cmd, "quit") == 0) { + if (quit) *quit = true; + return jok_int(NULL, 0); + } + return jerr("unknown cmd"); +} + char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line, bool *quit) { + while (*line == ' ') line++; + if (*line == '{') { + jv_t *req = jv_parse(line); + if (!req) return jerr("malformed json"); + char *reply = cmd_json(a, s, in, req, quit); + jv_free(req); + return reply; + } + char verb[32] = {0}; const char *arg = ""; const char *sp = strchr(line, ' '); @@ -80,6 +241,10 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line, app_compose(a, s); /* layout is a function of the frame: compose first */ return app_panes_json(a); } + if (strcmp(verb, "tabs") == 0) { + app_compose(a, s); + return app_tabs_json(a); + } if (strcmp(verb, "alive") == 0) { return strdup(app_should_quit(a) ? "false" : "true"); } diff --git a/src/jsonval.c b/src/jsonval.c new file mode 100644 index 0000000..acc3334 --- /dev/null +++ b/src/jsonval.c @@ -0,0 +1,261 @@ +#define _GNU_SOURCE +#include "jsonval.h" + +#include <stdlib.h> +#include <string.h> + +typedef struct { + const char *p; + bool bad; +} P; + +static jv_t *parse_value(P *s); + +static void skip_ws(P *s) { + while (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' || *s->p == '\r') s->p++; +} + +static jv_t *jv_new(jv_kind_t k) { + jv_t *v = calloc(1, sizeof *v); + v->kind = k; + return v; +} + +static void utf8_put(char **out, unsigned cp) { + char *o = *out; + if (cp < 0x80) *o++ = (char)cp; + else if (cp < 0x800) { + *o++ = (char)(0xc0 | (cp >> 6)); + *o++ = (char)(0x80 | (cp & 0x3f)); + } else if (cp < 0x10000) { + *o++ = (char)(0xe0 | (cp >> 12)); + *o++ = (char)(0x80 | ((cp >> 6) & 0x3f)); + *o++ = (char)(0x80 | (cp & 0x3f)); + } else { + *o++ = (char)(0xf0 | (cp >> 18)); + *o++ = (char)(0x80 | ((cp >> 12) & 0x3f)); + *o++ = (char)(0x80 | ((cp >> 6) & 0x3f)); + *o++ = (char)(0x80 | (cp & 0x3f)); + } + *out = o; +} + +static unsigned hex4(const char *p) { + unsigned v = 0; + for (int i = 0; i < 4; i++) { + char c = p[i]; + v <<= 4; + if (c >= '0' && c <= '9') v |= (unsigned)(c - '0'); + else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10); + } + return v; +} + +static char *parse_string_raw(P *s, size_t *out_len) { + if (*s->p != '"') { + s->bad = true; + return NULL; + } + s->p++; + const char *start = s->p; + size_t max = strlen(start) + 1; + char *buf = malloc(max); + char *o = buf; + while (*s->p && *s->p != '"') { + if (*s->p == '\\') { + s->p++; + switch (*s->p) { + case 'n': *o++ = '\n'; break; + case 't': *o++ = '\t'; break; + case 'r': *o++ = '\r'; break; + case 'b': *o++ = '\b'; break; + case 'f': *o++ = '\f'; break; + case '/': *o++ = '/'; break; + case '"': *o++ = '"'; break; + case '\\': *o++ = '\\'; break; + case 'u': { + unsigned cp = hex4(s->p + 1); + s->p += 4; + if (cp >= 0xd800 && cp < 0xdc00 && s->p[1] == '\\' && s->p[2] == 'u') { + unsigned lo = hex4(s->p + 3); + s->p += 6; + cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00); + } + utf8_put(&o, cp); + break; + } + default: + s->bad = true; + free(buf); + return NULL; + } + s->p++; + } else { + *o++ = *s->p++; + } + } + if (*s->p != '"') { + s->bad = true; + free(buf); + return NULL; + } + s->p++; + *o = 0; + if (out_len) *out_len = (size_t)(o - buf); + return buf; +} + +static jv_t *parse_value(P *s) { + skip_ws(s); + char c = *s->p; + + if (c == '{') { + s->p++; + jv_t *v = jv_new(JV_OBJ); + skip_ws(s); + if (*s->p == '}') { + s->p++; + return v; + } + for (;;) { + skip_ws(s); + char *key = parse_string_raw(s, NULL); + if (s->bad) { jv_free(v); return NULL; } + skip_ws(s); + if (*s->p != ':') { s->bad = true; free(key); jv_free(v); return NULL; } + s->p++; + jv_t *val = parse_value(s); + if (!val) { free(key); jv_free(v); return NULL; } + v->keys = realloc(v->keys, (v->len + 1) * sizeof *v->keys); + v->kids = realloc(v->kids, (v->len + 1) * sizeof *v->kids); + v->keys[v->len] = key; + v->kids[v->len] = val; + v->len++; + skip_ws(s); + if (*s->p == ',') { s->p++; continue; } + if (*s->p == '}') { s->p++; return v; } + s->bad = true; + jv_free(v); + return NULL; + } + } + + if (c == '[') { + s->p++; + jv_t *v = jv_new(JV_ARR); + skip_ws(s); + if (*s->p == ']') { + s->p++; + return v; + } + for (;;) { + jv_t *val = parse_value(s); + if (!val) { jv_free(v); return NULL; } + v->kids = realloc(v->kids, (v->len + 1) * sizeof *v->kids); + v->kids[v->len++] = val; + skip_ws(s); + if (*s->p == ',') { s->p++; continue; } + if (*s->p == ']') { s->p++; return v; } + s->bad = true; + jv_free(v); + return NULL; + } + } + + if (c == '"') { + size_t len = 0; + char *str = parse_string_raw(s, &len); + if (!str) return NULL; + jv_t *v = jv_new(JV_STR); + v->str = str; + v->len = len; + return v; + } + + if (strncmp(s->p, "true", 4) == 0) { + s->p += 4; + jv_t *v = jv_new(JV_BOOL); + v->b = true; + return v; + } + if (strncmp(s->p, "false", 5) == 0) { + s->p += 5; + jv_t *v = jv_new(JV_BOOL); + v->b = false; + return v; + } + if (strncmp(s->p, "null", 4) == 0) { + s->p += 4; + return jv_new(JV_NULL); + } + + { + char *end = NULL; + double d = strtod(s->p, &end); + if (end == s->p) { + s->bad = true; + return NULL; + } + s->p = end; + jv_t *v = jv_new(JV_NUM); + v->num = d; + return v; + } +} + +jv_t *jv_parse(const char *text) { + P s = {.p = text}; + jv_t *v = parse_value(&s); + if (!v) return NULL; + skip_ws(&s); + if (*s.p) { /* trailing garbage */ + jv_free(v); + return NULL; + } + return v; +} + +void jv_free(jv_t *v) { + if (!v) return; + for (size_t i = 0; i < v->len; i++) { + if (v->kids) jv_free(v->kids[i]); + if (v->keys) free(v->keys[i]); + } + free(v->kids); + free(v->keys); + free(v->str); + free(v); +} + +const jv_t *jv_get(const jv_t *obj, const char *key) { + if (!obj || obj->kind != JV_OBJ) return NULL; + for (size_t i = obj->len; i-- > 0;) /* last wins */ + if (strcmp(obj->keys[i], key) == 0) return obj->kids[i]; + return NULL; +} + +const char *jv_str(const jv_t *v, const char *fallback) { + return v && v->kind == JV_STR ? v->str : fallback; +} + +long jv_int(const jv_t *v, long fallback) { + if (!v) return fallback; + if (v->kind == JV_NUM) return (long)v->num; + if (v->kind == JV_STR) return strtol(v->str, NULL, 10); + return fallback; +} + +bool jv_bool(const jv_t *v, bool fallback) { + return v && v->kind == JV_BOOL ? v->b : fallback; +} + +const char *jv_gets(const jv_t *obj, const char *key, const char *fallback) { + return jv_str(jv_get(obj, key), fallback); +} +long jv_geti(const jv_t *obj, const char *key, long fallback) { + return jv_int(jv_get(obj, key), fallback); +} +bool jv_getb(const jv_t *obj, const char *key, bool fallback) { + return jv_bool(jv_get(obj, key), fallback); +} diff --git a/src/jsonval.h b/src/jsonval.h new file mode 100644 index 0000000..34cc597 --- /dev/null +++ b/src/jsonval.h @@ -0,0 +1,40 @@ +/* A small JSON reader, for the control API (D3). We already emit JSON; this + * is the other half. Deliberately minimal: no streaming, no comments, no + * duplicate-key policy beyond "last wins". */ +#ifndef SL0PTTY_JSONVAL_H +#define SL0PTTY_JSONVAL_H + +#include <stdbool.h> +#include <stddef.h> + +typedef enum { + JV_NULL, JV_BOOL, JV_NUM, JV_STR, JV_ARR, JV_OBJ, +} jv_kind_t; + +typedef struct jv jv_t; + +struct jv { + jv_kind_t kind; + bool b; + double num; + char *str; /* JV_STR: NUL-terminated, UTF-8 */ + size_t len; /* JV_STR bytes; JV_ARR/JV_OBJ element count */ + jv_t **kids; /* JV_ARR/JV_OBJ values */ + char **keys; /* JV_OBJ keys */ +}; + +/* NULL on malformed input. */ +jv_t *jv_parse(const char *text); +void jv_free(jv_t *v); + +const jv_t *jv_get(const jv_t *obj, const char *key); +const char *jv_str(const jv_t *v, const char *fallback); +long jv_int(const jv_t *v, long fallback); +bool jv_bool(const jv_t *v, bool fallback); + +/* Convenience: obj.key as a string/int/bool, with a fallback. */ +const char *jv_gets(const jv_t *obj, const char *key, const char *fallback); +long jv_geti(const jv_t *obj, const char *key, long fallback); +bool jv_getb(const jv_t *obj, const char *key, bool fallback); + +#endif /* SL0PTTY_JSONVAL_H */ |
