From 0054ea8452a811f2745d72ce4b7764ea158f1d5f Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 11:08:29 +0100 Subject: Add a web surface: the same spec as a page vega web MODULE serves the screen to a browser (127.0.0.1 unless given --listen): one dependency-free page draws the title, fields, lists as listboxes and tables, with tones as colors and the focus highlighted. It asks for the screen as JSON with the rows it can show per list or table, so only those cells are evaluated, scrolled like the TUI around the focus. Keys go as KeyboardEvent.code, which is Pure's key naming already; typed text commits with Enter; a click or the wheel moves the focus through pure_app_point; an event stream tells every open page of each change. Scrolling, key presses and text commits are now shared helpers. The checks drive it against the model (1,200 random steps: title, field, rows, focus, the window of items with badges and tones), check a table's columns and cells, post at random to every example, and in headless Chromium over the DevTools protocol press keys, click a row and type Korean text into the page itself. --- src/web.c | 436 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 src/web.c (limited to 'src/web.c') diff --git a/src/web.c b/src/web.c new file mode 100644 index 0000000..a48401a --- /dev/null +++ b/src/web.c @@ -0,0 +1,436 @@ +#define _GNU_SOURCE +#include "vega.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* vega web: the screen as a web page, a GUI surface. The page asks for the + * screen as JSON with the rows it can show per list or table, so only the + * cells on the page are evaluated; it sends keys (KeyboardEvent.code names are + * Pure's), committed text and clicks on rows, and an event stream tells every + * open page when the app changed. + * + * GET / the page + * GET /screen?rows=R&first=F,G title, fields, each list or table with R rows + * from its last first row (F, G, ... by part), keys + * POST /key KEYNAME press and release the input bound to the key + * POST /text UTF-8 commit text to the screen's text input + * POST /point PART ROW focus a row of a list or table (0 first) + * GET /events server-sent events: one per change */ + +enum { CLIENTS = 64, REQUEST = 1 << 16, ROWS_MAX = 500 }; + +typedef struct { char *bytes; size_t used, capacity; } Buffer; + +typedef struct { + int fd; + bool events; /* an open event stream */ + bool closing; + char in[REQUEST]; + size_t in_used; + Buffer out; + size_t out_at; +} Client; + +static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", "good", "warning", "danger"}; +static volatile sig_atomic_t stopping; + +static void on_stop(int signal_number) { + (void)signal_number; + stopping = 1; +} + +static void put(Buffer *b, const void *bytes, size_t size) { + if (b->used + size > b->capacity) { + size_t capacity = b->capacity ? b->capacity : 4096; + while (capacity < b->used + size) capacity *= 2; + char *grown = realloc(b->bytes, capacity); + if (!grown) return; + b->bytes = grown; + b->capacity = capacity; + } + memcpy(b->bytes + b->used, bytes, size); + b->used += size; +} + +static void puts_(Buffer *b, const char *text) { put(b, text, strlen(text)); } + +static void putf(Buffer *b, const char *format, ...) __attribute__((format(printf, 2, 3))); +#include +static void putf(Buffer *b, const char *format, ...) { + char line[256]; + va_list args; + va_start(args, format); + int n = vsnprintf(line, sizeof(line), format, args); + va_end(args); + if (n > 0) put(b, line, (size_t)n < sizeof(line) ? (size_t)n : sizeof(line) - 1); +} + +/* A JSON string: UTF-8 as it is, quotes, backslashes and control characters escaped. */ +static void json_string(Buffer *b, const char *text, size_t length) { + put(b, "\"", 1); + for (size_t i = 0; i < length; ++i) { + unsigned char c = (unsigned char)text[i]; + if (c == '"' || c == '\\') { put(b, "\\", 1); put(b, &text[i], 1); } + else if (c < 0x20 || c == 0x7F) putf(b, "\\u%04x", c); + else put(b, &text[i], 1); + } + put(b, "\"", 1); +} + +static void json_name(Buffer *b, const char *name) { json_string(b, name, strlen(name)); } + +/* A view's text at a row, or null. */ +static void json_view(Buffer *b, PureApp *app, uint32_t view, size_t row) { + char text[VEGA_TEXT]; + size_t length; + if (vega_text(app, view, row, text, &length)) json_string(b, text, length); + else puts_(b, "null"); +} + +static void screen_json(Buffer *b, PureApp *app, const VegaScreen *screen, size_t visible, size_t *firsts) { + const PureModule *m = app->module; + puts_(b, "{\"title\":"); + if (screen->title >= 0) json_view(b, app, (uint32_t)screen->title, 0); + else puts_(b, "null"); + puts_(b, ",\"fields\":["); + bool comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind != VEGA_FIELD) continue; + puts_(b, comma ? ",{\"name\":" : "{\"name\":"); + json_name(b, m->view_names[part->view]); + puts_(b, ",\"text\":"); + json_view(b, app, part->view, 0); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"parts\":["); + comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + size_t rows = vega_rows(app, part), focus = vega_focus(app, part); + size_t first = vega_scroll(firsts[i], focus, visible, rows); + firsts[i] = first; + puts_(b, comma ? ",{\"part\":" : "{\"part\":"); + putf(b, "%u,\"name\":", i); + json_name(b, m->view_names[part->view]); + putf(b, ",\"kind\":\"%s\",\"rows\":%zu,\"focus\":%zu,\"first\":%zu,\"pointable\":%s,\"columns\":[", + part->kind == VEGA_LIST ? "list" : "table", rows, rows ? focus : 0, first, part->pointer >= 0 ? "true" : "false"); + if (part->kind == VEGA_TABLE) + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_name(b, m->view_names[part->columns[c]]); + } + puts_(b, "],\"items\":["); + for (size_t row = first; row < first + visible && row < rows; ++row) { + putf(b, "%s{\"row\":%zu,\"tone\":\"%s\"", row > first ? "," : "", row, + tone_names[vega_tone(app, part, row)]); + if (part->kind == VEGA_LIST) { + const int columns[] = {part->label, part->badge, part->detail}; + const char *const names[] = {"label", "badge", "detail"}; + for (unsigned k = 0; k < 3; ++k) { + if (columns[k] < 0) continue; + putf(b, ",\"%s\":", names[k]); + json_view(b, app, (uint32_t)columns[k], row); + } + } else { + puts_(b, ",\"cells\":["); + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_view(b, app, part->columns[c], row); + } + puts_(b, "]"); + } + puts_(b, "}"); + } + puts_(b, "]}"); + comma = true; + } + puts_(b, "],\"keys\":["); + comma = false; + for (uint32_t i = 0; i < m->inputs; ++i) { + if (m->input[i].kind != PURE_KIND_KEY) continue; + bool bound = false; + for (uint32_t h = 0; h < m->handlers && !bound; ++h) bound = m->handler[h].event == 1 + 2 * i; + if (!bound) continue; + puts_(b, comma ? ",{\"input\":" : "{\"input\":"); + json_name(b, m->input_names[i]); + puts_(b, ",\"key\":"); + json_name(b, m->input_keys[i]); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"prompt\":"); + if (screen->prompt >= 0) json_name(b, m->input_names[screen->prompt]); + else puts_(b, "null"); + putf(b, ",\"events\":%llu}", (unsigned long long)app->events); +} + +/* The page: one file, no dependencies. It keeps each part's first row, asks + * for as many rows as fit, and redraws when the event stream says so. */ +static const char *const page_parts[] = { +"" +"Vega
", +"

" +"
" +""}; + +/* The page, joined once from its pieces. */ +static const Buffer *page(void) { + static Buffer joined; + if (!joined.used) + for (size_t i = 0; i < sizeof(page_parts) / sizeof(*page_parts); ++i) puts_(&joined, page_parts[i]); + return &joined; +} + +static void respond(Client *c, int status, const char *reason, const char *type, const char *body, size_t length) { + putf(&c->out, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nCache-Control: no-store\r\n%s\r\n", + status, reason, type, length, c->closing ? "Connection: close\r\n" : ""); + put(&c->out, body, length); +} + +static bool query_number(const char *query, const char *name, size_t *out) { + size_t length = strlen(name); + for (const char *at = query; at && *at; at = strchr(at, '&') ? strchr(at, '&') + 1 : NULL) + if (!strncmp(at, name, length) && at[length] == '=') { + *out = strtoul(at + length + 1, NULL, 10); + return true; + } + return false; +} + +/* first=F,G,...: the parts' first rows, in the order of the screen's parts. */ +static void query_firsts(const char *query, const VegaScreen *screen, size_t *firsts) { + const char *at = query ? strstr(query, "first=") : NULL; + if (!at || (at != query && at[-1] != '&')) return; + at += 6; + for (unsigned i = 0; i < screen->count && *at && *at != '&'; ++i) { + if (screen->parts[i].kind == VEGA_FIELD) continue; + firsts[i] = strtoul(at, (char **)&at, 10); + if (*at == ',') ++at; + } +} + +/* One request. Returns whether the app changed. */ +static bool handle(Client *c, PureApp *app, const VegaScreen *screen, const char *method, char *target, + const char *body, size_t body_size) { + char *query = strchr(target, '?'); + if (query) *query++ = 0; + bool get = !strcmp(method, "GET"), post = !strcmp(method, "POST"); + uint64_t before = app->events; + if (get && !strcmp(target, "/")) { + respond(c, 200, "OK", "text/html; charset=utf-8", page()->bytes, page()->used); + return false; + } + if (get && !strcmp(target, "/screen")) { + size_t visible = 50, firsts[VEGA_PARTS] = {0}; + query_number(query, "rows", &visible); + if (visible > ROWS_MAX) visible = ROWS_MAX; + query_firsts(query, screen, firsts); + Buffer json = {0}; + screen_json(&json, app, screen, visible, firsts); + respond(c, 200, "OK", "application/json", json.bytes ? json.bytes : "", json.used); + free(json.bytes); + return false; + } + if (get && !strcmp(target, "/events")) { + puts_(&c->out, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n\r\n"); + putf(&c->out, "data: %llu\n\n", (unsigned long long)app->events); + c->events = true; + return false; + } + if (post && (!strcmp(target, "/key") || !strcmp(target, "/text") || !strcmp(target, "/point"))) { + char text[VEGA_TEXT]; + size_t length = body_size < sizeof(text) - 1 ? body_size : sizeof(text) - 1; + memcpy(text, body, length); + text[length] = 0; + bool ok = false; + if (target[1] == 'k') ok = vega_press(app, text); + else if (target[1] == 't') ok = vega_commit(app, screen, text, length); + else { + unsigned part = 0; + long row = -1; + ok = sscanf(text, "%u %ld", &part, &row) == 2 && part < screen->count && row >= 0 && + screen->parts[part].kind != VEGA_FIELD && vega_point(app, &screen->parts[part], (size_t)row); + } + const char *answer = ok ? "{\"ok\":true}" : "{\"ok\":false}"; + respond(c, 200, "OK", "application/json", answer, strlen(answer)); + return app->events != before; + } + respond(c, 404, "Not Found", "text/plain", "not found\n", 10); + return false; +} + +/* The requests complete in c->in, answered in order. */ +static bool serve(Client *c, PureApp *app, const VegaScreen *screen) { + bool changed = false; + while (!c->events && !c->closing) { + char *end = memmem(c->in, c->in_used, "\r\n\r\n", 4); + if (!end) { + if (c->in_used == sizeof(c->in)) c->closing = true; + return changed; + } + size_t head = (size_t)(end - c->in) + 4, body_size = 0; + *end = 0; + char *length = strcasestr(c->in, "\r\ncontent-length:"); + if (length) body_size = strtoul(length + 17, NULL, 10); + if (body_size > sizeof(c->in) - head) { c->closing = true; return changed; } + if (c->in_used < head + body_size) { *end = '\r'; return changed; } + if (strcasestr(c->in, "\r\nconnection: close")) c->closing = true; + char method[8] = "", target[1024] = ""; + if (sscanf(c->in, "%7s %1023s", method, target) == 2) + changed = handle(c, app, screen, method, target, c->in + head, body_size) || changed; + else c->closing = true; + memmove(c->in, c->in + head + body_size, c->in_used - head - body_size); + c->in_used -= head + body_size; + } + return changed; +} + +static void drop(Client *c) { + close(c->fd); + free(c->out.bytes); + c->fd = -1; +} + +int vega_web(PureApp *app, const VegaScreen *screen, const char *listen_on, unsigned port) { + int listener = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0), one = 1; + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port)}; + socklen_t size = sizeof(address); + if (listener < 0 || inet_pton(AF_INET, listen_on, &address.sin_addr) != 1 || + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) || + bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, 64) || + getsockname(listener, (struct sockaddr *)&address, &size)) { + fprintf(stderr, "vega: cannot listen on %s:%u\n", listen_on, port); + return 2; + } + printf("{\"port\":%u}\n", ntohs(address.sin_port)); + fflush(stdout); + signal(SIGINT, on_stop); + signal(SIGTERM, on_stop); + signal(SIGPIPE, SIG_IGN); + static Client clients[CLIENTS]; + for (unsigned i = 0; i < CLIENTS; ++i) clients[i].fd = -1; + bool ticking = false; + for (uint32_t h = 0; h < app->module->handlers; ++h) ticking = ticking || app->module->handler[h].event == 0; + while (!stopping) { + struct pollfd fds[CLIENTS + 1]; + unsigned slot[CLIENTS + 1], count = 1; + fds[0] = (struct pollfd){listener, POLLIN, 0}; + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + fds[count] = (struct pollfd){c->fd, (short)(POLLIN | (c->out_at < c->out.used ? POLLOUT : 0)), 0}; + slot[count++] = i; + } + int ready = poll(fds, count, ticking ? 33 : 1000); + if (ready < 0 && errno != EINTR) break; + bool changed = false; + if (ready == 0 && ticking) changed = pure_app_frame(app) == PURE_OK; + if (fds[0].revents & POLLIN) + for (int fd; (fd = accept4(listener, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC)) >= 0;) { + unsigned free_slot = CLIENTS; + for (unsigned i = 0; i < CLIENTS && free_slot == CLIENTS; ++i) if (clients[i].fd < 0) free_slot = i; + if (free_slot == CLIENTS) { close(fd); continue; } + clients[free_slot] = (Client){.fd = fd}; + } + for (unsigned k = 1; k < count; ++k) { + Client *c = &clients[slot[k]]; + if (fds[k].revents & (POLLIN | POLLHUP | POLLERR)) { + ssize_t n = read(c->fd, c->in + c->in_used, sizeof(c->in) - c->in_used); + if (n > 0) { + c->in_used += (size_t)n; + changed = serve(c, app, screen) || changed; + } else if (n == 0 || (errno != EAGAIN && errno != EINTR)) { drop(c); continue; } + } + } + /* Every open page hears of a change once. */ + if (changed) + for (unsigned i = 0; i < CLIENTS; ++i) + if (clients[i].fd >= 0 && clients[i].events) + putf(&clients[i].out, "data: %llu\n\n", (unsigned long long)app->events); + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + while (c->out_at < c->out.used) { + ssize_t n = write(c->fd, c->out.bytes + c->out_at, c->out.used - c->out_at); + if (n <= 0) { + if (n < 0 && (errno == EAGAIN || errno == EINTR)) break; + c->closing = true; + c->out_at = c->out.used; + break; + } + c->out_at += (size_t)n; + } + if (c->out_at == c->out.used) c->out_at = c->out.used = 0; + if (c->closing && !c->out.used) drop(c); + } + } + for (unsigned i = 0; i < CLIENTS; ++i) if (clients[i].fd >= 0) drop(&clients[i]); + close(listener); + return 0; +} -- cgit v1.2.3