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. --- Makefile | 2 +- docs/design/001-first-component.md | 26 ++- src/draw.c | 4 +- src/main.c | 33 ++- src/screen.c | 26 +++ src/vega.h | 12 + src/web.c | 436 +++++++++++++++++++++++++++++++++++++ tests/browser.mjs | 91 ++++++++ tests/check.py | 145 ++++++++++++ 9 files changed, 752 insertions(+), 23 deletions(-) create mode 100644 src/web.c create mode 100644 tests/browser.mjs diff --git a/Makefile b/Makefile index 7447bef..2dde6d2 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ AUP ?= ../aup PURE_SOURCES = $(PURE)/module.c $(PURE)/module-list.c $(PURE)/module-save.c $(PURE)/module-store.c \ $(PURE)/module-text.c $(PURE)/store-tree.c -SOURCES = src/screen.c src/draw.c src/width.c src/main.c +SOURCES = src/screen.c src/draw.c src/width.c src/web.c src/main.c .PHONY: all check all: $(BUILD)/vega diff --git a/docs/design/001-first-component.md b/docs/design/001-first-component.md index fcfa3df..df462e5 100644 --- a/docs/design/001-first-component.md +++ b/docs/design/001-first-component.md @@ -38,6 +38,7 @@ view tasks_list_focus = at.position | TUI | `vega tui app.pmod` | 대체 화면, 원시 입력. 제목·필드·목록·표·키 목록·입력 줄. 포커스 행을 최소 스크롤로 화면에 두고 **보이는 행의 칸만** 계산한다. 터미널에 보이는 격자를 기억해 **바뀐 칸만** 보낸다. 크기 변경(SIGWINCH), frame 핸들러가 있으면 60 Hz | | CLI | `vega cli app.pmod < script` | 파이프용 평문. 목록은 `> ` 표시와 `[badge]`, 표는 표시 폭으로 정렬한 열. 터미널이고 `NO_COLOR`가 없을 때만 색 | | NUI | `vega say app.pmod < script` | 문장. 이벤트마다 포커스 항목을 말하고(`Item 2 of 3: danger, milk, urgent.`), 실패하면 `Could not toggle.`, `show`에서 화면 전체 | +| GUI | `vega web app.pmod [--listen 주소] [--port N]` | 웹 페이지. 제목·필드·목록(`listbox`)·표·키 목록·입력 칸. 키, 행 클릭, 휠, 입력을 받고 열린 페이지마다 변경을 알린다. 보이는 행만 계산한다 | 포커스는 표면에서도 옮긴다. TUI에서 목록·표의 행을 클릭하면 그 행이 포커스가 되고, 휠은 포커스를 한 행씩 옮긴다 (SGR 마우스 보고). 스크립트는 `point 보기 행`(0부터)과 `click 줄 칸`(마지막으로 보인 프레임, 1부터)으로 같은 일을 한다. @@ -52,6 +53,29 @@ Tab이 키 모드로 바꾸면 글자·숫자·Space가 `KeyA`·`Digit0`·`Space 표시 폭은 Vega가 맡는다. 한글·CJK·그림 이모지는 두 칸, 결합 문자·변이 선택자·ZWJ는 0칸, 제어 문자는 제어 그림(U+2400대)으로 보인다. grapheme 단위 자르기는 아직 없어 ZWJ 이모지 묶음은 근사다. +## 웹 (GUI) + +`vega web app.pmod`는 같은 명세를 브라우저에 보인다. 기본은 이 기기(127.0.0.1)에서만 받는다. 다른 기기에서 열려면 +`--listen 0.0.0.0`처럼 주소를 준다. 앱이 곧 서버의 상태이므로 여러 탭이 한 앱을 함께 본다. + +| 요청 | 하는 일 | +|---|---| +| `GET /` | 페이지 하나(HTML·CSS·JS, 의존성 없음, 5 KB). 밝은·어두운 모드, 톤은 색, 포커스는 강조 | +| `GET /screen?rows=R&first=F,…` | 화면 JSON. 목록·표마다 마지막 첫 행 F에서 R행만, TUI와 같은 최소 스크롤로 포커스를 창 안에 둔다 | +| `POST /key` `/text` `/point` | 키 누름·뗌, 확정된 글, 행 가리키기(`파트 행`) | +| `GET /events` | 서버 전송 이벤트. 앱이 바뀔 때마다 열린 페이지 모두에 한 번 | + +페이지의 키는 브라우저의 `KeyboardEvent.code`를 그대로 보낸다. Pure의 키 이름(`ArrowUp`, `KeyA`, `Space`, `Digit1`)이 같은 +이름 체계라 변환표가 없다. 입력 칸에서는 글이 칸으로 가고 Enter가 확정한다. 위아래 화살표와 PageUp·PageDown은 앱으로 간다. +행을 클릭하면 그 행이, 휠을 굴리면 포커스가 한 행씩 옮겨 간다(`pure_app_point`). 목록은 `listbox`와 `aria-selected`로 +그려 화면 낭독기가 포커스를 읽는다. + +검증: 무작위 키·글·행 가리키기 1,200단계에서 화면 JSON(제목, 필드, 행 수, 포커스, 창의 행과 배지·톤)이 모델과 같고, +열린 이벤트 스트림이 변경을 듣고, 저장소가 다음 실행으로 이어진다. 헤드리스 Chromium을 DevTools 프로토콜로 움직여 +페이지 자체도 확인한다(`tests/browser.mjs`). ArrowUp이 포커스를 옮기고, 행 클릭이 그 행을 고르고, Space가 완료(흐린 톤, +배지)를 표시하고, 한글을 입력하고 Enter를 치면 새 항목이 생겨 포커스를 받는다. 모든 예제에 무작위 요청을 보내도 응답이 +올바르다. + ## 셀 단위 갱신 TUI는 매 변경마다 격자를 앱에서 다시 그리지만(보이는 칸만 계산), 터미널에는 지금 보이는 것과 다른 칸만 보낸다. @@ -111,5 +135,5 @@ CLI나 문장 표면이 그대로 이어받는다. Vega에도 앱에도 저장 ## 남은 것 -- GUI 표면, 음성 합성으로 가는 NUI. +- 음성 합성으로 가는 NUI, 창이 있는 데스크톱 GUI(지금 GUI는 브라우저다). - 문장의 언어. 지금 NUI의 틀 문장은 영어이고 값은 앱의 text 그대로다. diff --git a/src/draw.c b/src/draw.c index 3c7a59d..738c2db 100644 --- a/src/draw.c +++ b/src/draw.c @@ -412,9 +412,7 @@ void vega_draw(VegaGrid *grid, PureApp *app, const VegaScreen *screen, const cha write_cstr(&head, count); unsigned area = share - 1, visible = part->kind == VEGA_TABLE && area ? area - 1 : area; size_t *first = &grid->first[i]; - if (focus < *first) *first = focus; - if (visible && focus >= *first + visible) *first = focus - visible + 1; - if (*first + visible > rows) *first = rows > visible ? rows - visible : 0; + *first = vega_scroll(*first, focus, visible, rows); if (part->kind == VEGA_LIST) draw_list(grid, app, part, (int)i, *first, row + 1, area); else draw_table(grid, app, part, (int)i, *first, row + 1, area); row += share; diff --git a/src/main.c b/src/main.c index 4e5c79b..46b672f 100644 --- a/src/main.c +++ b/src/main.c @@ -17,6 +17,8 @@ * tui --paint WxH the bytes each frame sends to the terminal, driven by a script * cli plain lines for pipes, driven by a script * say sentences (the first natural-language surface), driven by a script + * web [--listen ADDRESS] [--port N] + * a web page (a GUI), on 127.0.0.1 unless another address is given * With --store the app's states live in that tree store: the app continues * where it was, whichever surface runs it. * Script commands, one per line: key KEYNAME | text HEX|- | type HEX | enter | @@ -63,11 +65,7 @@ static bool load(const char *path, const char *store) { return true; } -static int key_input(const char *key) { - for (uint32_t i = 0; i < module.inputs; ++i) - if (module.input[i].kind == PURE_KIND_KEY && !strcmp(module.input_keys[i], key)) return (int)i; - return -1; -} +static int key_input(const char *key) { return vega_key_input(&module, key); } static bool frames(void) { for (uint32_t h = 0; h < module.handlers; ++h) if (module.handler[h].event == 0) return true; @@ -77,16 +75,10 @@ static bool frames(void) { /* A key press and release; false when the app has no such key or the press failed. */ static bool press(const char *key, int *input) { *input = key_input(key); - if (*input < 0) return false; - bool ok = pure_app_input(&app, (uint32_t)*input, PURE_EDGE_DOWN) == PURE_OK; - pure_app_input(&app, (uint32_t)*input, PURE_EDGE_UP); - return ok; + return *input >= 0 && vega_press(&app, key); } -static bool commit(const char *text, size_t length) { - if (screen.prompt < 0) return false; - return pure_app_text(&app, (uint32_t)screen.prompt, (PureSpan){(const unsigned char *)text, length}) == PURE_OK; -} +static bool commit(const char *text, size_t length) { return vega_commit(&app, &screen, text, length); } static const VegaPart *part_named(const char *name) { for (unsigned i = 0; i < screen.count; ++i) @@ -383,7 +375,7 @@ static int interactive(void) { /* ---- scripts -------------------------------------------------------- */ -typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI } Surface; +typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI, SURFACE_WEB } Surface; /* painting: show writes the terminal bytes of the frame, not the grid. */ static int script(Surface surface, unsigned width, unsigned height, bool painting) { @@ -466,11 +458,14 @@ static int script(Surface surface, unsigned width, unsigned height, bool paintin } int main(int argc, char **argv) { - unsigned width = 0, height = 0; + unsigned width = 0, height = 0, port = 8080; bool dumped = false, painting = false, usage = argc < 3 || argc % 2 == 0; - const char *store = NULL; + const char *store = NULL, *listen_on = "127.0.0.1"; for (int i = 3; !usage && i + 1 < argc; i += 2) { if (!strcmp(argv[i], "--store")) store = argv[i + 1]; + else if (!strcmp(argv[i], "--listen") && !strcmp(argv[1], "web")) listen_on = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && !strcmp(argv[1], "web") && sscanf(argv[i + 1], "%u", &port) == 1 && + port < 65536) {} else if ((!strcmp(argv[i], "--dump") || !strcmp(argv[i], "--paint")) && sscanf(argv[i + 1], "%ux%u", &width, &height) == 2 && width && height && width <= 1000 && height <= 1000) dumped = true, painting = !strcmp(argv[i], "--paint"); @@ -479,6 +474,7 @@ int main(int argc, char **argv) { if (usage || (dumped && strcmp(argv[1], "tui")) || (!strcmp(argv[1], "check") && argc != 3)) { fputs("vega tui|cli|say MODULE.pmod [--store PATH]\n" "vega tui MODULE.pmod [--store PATH] --dump|--paint WIDTHxHEIGHT < script\n" + "vega web MODULE.pmod [--store PATH] [--listen ADDRESS] [--port N]\n" "vega check MODULE.pmod\n", stderr); return 2; } @@ -487,10 +483,11 @@ int main(int argc, char **argv) { return vega_check(&module, print_finding, argv[2]) ? 1 : 0; } Surface surface = !strcmp(argv[1], "cli") ? SURFACE_CLI : !strcmp(argv[1], "say") ? SURFACE_SAY : - !strcmp(argv[1], "tui") ? SURFACE_TUI : (Surface)-1; + !strcmp(argv[1], "tui") ? SURFACE_TUI : !strcmp(argv[1], "web") ? SURFACE_WEB : (Surface)-1; if ((int)surface < 0) { fprintf(stderr, "vega: unknown surface %s\n", argv[1]); return 2; } if (!load(argv[2], store)) return 2; - int status = surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height, painting); + int status = surface == SURFACE_WEB ? vega_web(&app, &screen, listen_on, port) : + surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height, painting); /* Frame changes are stored with events; keep the last ones too. */ if (pure_app_save(&app) != PURE_OK) { fputs("vega: cannot save\n", stderr); status = status ? status : 1; } pure_app_stop(&app); diff --git a/src/screen.c b/src/screen.c index 3e8b2c4..bb50de1 100644 --- a/src/screen.c +++ b/src/screen.c @@ -157,6 +157,32 @@ size_t vega_focus(PureApp *app, const VegaPart *part) { return focus >= (double)(rows - 1) ? rows - 1 : (size_t)focus; } +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows) { + if (focus < first) first = focus; + if (visible && focus >= first + visible) first = focus - visible + 1; + if (first + visible > rows) first = rows > visible ? rows - visible : 0; + return first; +} + +int vega_key_input(const PureModule *m, const char *key) { + for (uint32_t i = 0; i < m->inputs; ++i) + if (m->input[i].kind == PURE_KIND_KEY && !strcmp(m->input_keys[i], key)) return (int)i; + return -1; +} + +bool vega_press(PureApp *app, const char *key) { + int input = vega_key_input(app->module, key); + if (input < 0) return false; + bool ok = pure_app_input(app, (uint32_t)input, PURE_EDGE_DOWN) == PURE_OK; + pure_app_input(app, (uint32_t)input, PURE_EDGE_UP); + return ok; +} + +bool vega_commit(PureApp *app, const VegaScreen *screen, const char *text, size_t length) { + return screen->prompt >= 0 && + pure_app_text(app, (uint32_t)screen->prompt, (PureSpan){(const unsigned char *)text, length}) == PURE_OK; +} + bool vega_point(PureApp *app, const VegaPart *part, size_t row) { return part->pointer >= 0 && pure_app_point(app, (uint32_t)part->pointer, row) == PURE_OK; } diff --git a/src/vega.h b/src/vega.h index 80e3f7d..98ed410 100644 --- a/src/vega.h +++ b/src/vega.h @@ -51,6 +51,15 @@ size_t vega_focus(PureApp *, const VegaPart *); /* Focus a row, as a user pointing at it (a click); false when the part's * focus is not a plain index position or the row is not there. */ bool vega_point(PureApp *, const VegaPart *, size_t row); +/* The first row to show of `rows` when `visible` fit: the least scrolling + * from first that keeps the focused row in view. */ +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows); +/* The key input answering a key name (Pure's names, as KeyboardEvent.code + * spells them), or -1; press sends its press and release. */ +int vega_key_input(const PureModule *, const char *key); +bool vega_press(PureApp *, const char *key); +/* Commit text to the screen's text input. */ +bool vega_commit(PureApp *, const VegaScreen *, const char *text, size_t length); VegaTone vega_tone(PureApp *, const VegaPart *, size_t row); /* Values evaluated so far, for checking that surfaces stay lazy. */ unsigned long vega_evaluations(void); @@ -68,6 +77,9 @@ unsigned vega_text_width(const char *text, size_t length); void vega_print(FILE *, PureApp *, const VegaScreen *, bool color); /* NUI: the screen as sentences (all), or only the focused item of the first list. */ void vega_say(FILE *, PureApp *, const VegaScreen *, bool all); +/* GUI: the screen as a web page on listen:port (0: any free port, printed as + * {"port":N}); runs until SIGINT or SIGTERM. */ +int vega_web(PureApp *, const VegaScreen *, const char *listen, unsigned port); /* TUI: a grid of cells, redrawn from the app on every change. first keeps each * list's scroll between frames; the focused row stays visible. shown is what 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; +} diff --git a/tests/browser.mjs b/tests/browser.mjs new file mode 100644 index 0000000..87ad584 --- /dev/null +++ b/tests/browser.mjs @@ -0,0 +1,91 @@ +// The web surface in a real browser: headless Chromium, driven over the +// DevTools protocol with what a user does (keys, a click on a row, typing +// Korean text and Enter), each step waiting for the page to show its effect. +// +// node tests/browser.mjs URL (the app: examples/todo.aup with tasks a, b, c) +// Prints one JSON line: the steps that held. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const url = process.argv[2]; +const profile = mkdtempSync(join(tmpdir(), 'vega-browser-')); +const chromium = spawn('chromium', ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, + '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=900,700', 'about:blank'], + { stdio: ['ignore', 'ignore', 'pipe'] }); +const endpoint = await new Promise((resolve, reject) => { + let seen = ''; + chromium.stderr.on('data', chunk => { + seen += chunk; + const found = seen.match(/DevTools listening on (ws:\/\/\S+)/); + if (found) resolve(found[1]); + }); + chromium.on('exit', code => reject(new Error(`chromium exited ${code}: ${seen.slice(-400)}`))); + setTimeout(() => reject(new Error('chromium did not start')), 20000); +}); + +const socket = new WebSocket(endpoint); +await new Promise(resolve => socket.addEventListener('open', resolve, { once: true })); +let next = 0; +const waiting = new Map(); +socket.addEventListener('message', event => { + const message = JSON.parse(event.data); + if (message.id && waiting.has(message.id)) { + const { resolve, reject } = waiting.get(message.id); + waiting.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } +}); +let session; +const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++next; + waiting.set(id, { resolve, reject }); + socket.send(JSON.stringify(session ? { id, method, params, sessionId: session } : { id, method, params })); +}); + +const steps = []; +async function finish(ok, error) { + console.log(JSON.stringify({ ok, steps, error })); + chromium.kill(); + rmSync(profile, { recursive: true, force: true }); + process.exit(ok ? 0 : 1); +} + +try { + const { targetId } = await send('Target.createTarget', { url }); + ({ sessionId: session } = await send('Target.attachToTarget', { targetId, flatten: true })); + const evaluate = async expression => (await send('Runtime.evaluate', { expression, returnByValue: true })).result.value; + const until = async (what, expression) => { + for (const end = Date.now() + 10000; Date.now() < end;) { + if (await evaluate(expression)) { steps.push(what); return; } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`${what}: never true: ${expression}; page: ${await evaluate('document.body.innerText')}`); + }; + const selected = `[...document.querySelectorAll('li')].findIndex(li => li.getAttribute('aria-selected') === 'true')`; + const key = async (code, key, keyCode, text) => { + await send('Input.dispatchKeyEvent', { type: 'keyDown', code, key, windowsVirtualKeyCode: keyCode, text }); + await send('Input.dispatchKeyEvent', { type: 'keyUp', code, key, windowsVirtualKeyCode: keyCode }); + }; + + await until('three tasks, the last focused', `document.querySelectorAll('li').length === 3 && ${selected} === 2`); + await until('title and field', `document.querySelector('h1').textContent === 'To do' && document.querySelector('dd').textContent === '3 tasks'`); + await key('ArrowUp', 'ArrowUp', 38); + await until('ArrowUp moves the focus', `${selected} === 1`); + const box = await evaluate(`(() => { const r = document.querySelectorAll('li')[0].getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()`); + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box[0], y: box[1], button: 'left', clickCount: 1 }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box[0], y: box[1], button: 'left', clickCount: 1 }); + await until('a click focuses its row', `${selected} === 0`); + await key('Space', ' ', 32, ' '); + await until('Space marks it done, muted', `document.querySelectorAll('li')[0].classList.contains('muted') && document.querySelector('li .badge')?.textContent === 'done'`); + await evaluate(`document.getElementById('entry').focus()`); + await send('Input.insertText', { text: '브라우저' }); + await key('Enter', 'Enter', 13, '\r'); + await until('typed text becomes a task', `document.querySelectorAll('li').length === 4 && document.querySelectorAll('li')[3].textContent === '브라우저' && ${selected} === 3`); + await until('the input is emptied', `document.getElementById('entry').value === ''`); + await finish(true); +} catch (error) { + await finish(false, String(error.message || error)); +} diff --git a/tests/check.py b/tests/check.py index e0d5996..51fbf7f 100644 --- a/tests/check.py +++ b/tests/check.py @@ -20,6 +20,10 @@ none in the examples. 7. Painting: the bytes the TUI sends, replayed by a small terminal emulator, give the very frame the dump shows, while sending only what changed. +8. Web: the GUI surface's screen JSON matches the model after random keys, + text and row clicks, holds only the rows asked for around the focus, and + every open page hears of each change. In headless Chromium (when node and + chromium are there) the page itself takes keys, a click and typed text. """ import argparse import json @@ -368,6 +372,119 @@ def painting(vega, module, seeds, sizes, steps=200): return compared, first_mean, later_mean +def web(vega, module, work, seeds, steps=150, visible=4): + """The web surface against the model: POST keys, text and row clicks, then + the screen JSON's title, field, rows, focus and the window of items.""" + import http.client + import signal + import threading + compared = 0 + for seed in seeds: + store = work / f'web-{seed}' + process = subprocess.Popen([str(vega), 'web', str(module), '--port', '0', '--store', str(store)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + port = json.loads(process.stdout.readline())['port'] + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=10) + + def call(method, path, body=None): + connection.request(method, path, body.encode() if isinstance(body, str) else body) + response = connection.getresponse() + return response.status, response.read() + + heard = [] + stream = http.client.HTTPConnection('127.0.0.1', port, timeout=10) + stream.request('GET', '/events') + events = stream.getresponse() + assert events.getheader('Content-Type') == 'text/event-stream' + listener = threading.Thread(target=lambda: [heard.append(line) for line in iter(events.readline, b'') + if line.startswith(b'data:')], daemon=True) + listener.start() + status, page = call('GET', '/') + assert status == 200 and b'EventSource' in page and b"role:'listbox'" in page, page[:80] + assert call('GET', '/nowhere')[0] == 404 + rng, model, first, part = random.Random(seed + 900), Todo(), 0, None + for _ in range(steps): + roll = rng.random() + if roll < 0.3: + word = rng.choice(WORDS) + status, answer = call('POST', '/text', word) + model.event('add', word) + elif roll < 0.45 and part is not None: + row = rng.randrange(len(model.tasks) + 1) + status, answer = call('POST', '/point', f'{part} {row}') + assert json.loads(answer)['ok'] == model.event('point', row), (seed, row) + else: + name = rng.choice(list(KEYS)) + status, answer = call('POST', '/key', KEYS[name]) + assert json.loads(answer)['ok'] == model.event(name), (seed, name) + assert status == 200 + status, body = call('GET', f'/screen?rows={visible}&first={first}') + screen = json.loads(body) + tasks, n = model.tasks, len(model.tasks) + (listed,) = screen['parts'] + part = listed['part'] + if model.at < first: first = model.at + if model.at >= first + visible: first = model.at - visible + 1 + if first + visible > n: first = max(n - visible, 0) + assert screen['title'] == 'To do' and screen['fields'] == [{'name': 'left', 'text': f'{n} tasks'}], screen + assert (listed['rows'], listed['focus'], listed['first']) == (n, model.at, first), (seed, listed, model.at) + window = tasks[first:first + visible] + assert [(item['row'], item['label'], item['badge'], item['tone']) for item in listed['items']] == \ + [(first + i, task['title'], Todo.badge(task), Todo.tone(task)) for i, task in enumerate(window)], seed + assert screen['prompt'] == 'add' and listed['pointable'] and listed['kind'] == 'list' + compared += 1 + process.send_signal(signal.SIGTERM) + assert process.wait(timeout=10) == 0, process.stderr.read() + listener.join(timeout=5) + assert len(heard) > steps // 2, len(heard) # a change each time, more or less merged + again = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() + assert again == model.cli(), (again, model.cli()) + return compared + + +class WebSurface: + """vega web on a free port; stops with SIGTERM.""" + + def __init__(self, vega, module, *args): + self.process = subprocess.Popen([str(vega), 'web', str(module), '--port', '0', *map(str, args)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.port = json.loads(self.process.stdout.readline())['port'] + + def call(self, method, path, body=None): + import http.client + connection = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10) + try: + connection.request(method, path, body.encode() if isinstance(body, str) else body) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + def stop(self): + import signal + self.process.send_signal(signal.SIGTERM) + assert self.process.wait(timeout=10) == 0, self.process.stderr.read() + + +def browser(vega, module, work): + """The page in headless Chromium: keys, a click on a row, typed text.""" + import shutil + if not shutil.which('node') or not shutil.which('chromium'): + return 'skipped' + store = work / 'browser-store' + assert run(vega, 'cli', module, '--store', store, stdin='text 61\ntext 62\ntext 63\n').returncode == 0 + surface = WebSurface(vega, module, '--store', store) + try: + result = run('node', HERE / 'browser.mjs', f'http://127.0.0.1:{surface.port}/') + finally: + surface.stop() + outcome = json.loads(result.stdout) + assert result.returncode == 0 and outcome['ok'], outcome + after = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() + assert after == ['To do', 'left: 4 tasks', 'tasks_list (4)', ' a [done] (muted)', ' b', ' c', '> 브라우저'], after + return len(outcome['steps']) + + def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] @@ -383,6 +500,19 @@ def scores(vega, module): '| 범수 3 gold\tgood'], tui say = run(vega, 'say', module, stdin='show\n' + '\n'.join(script) + '\n').stdout.splitlines() assert 'Item 1 of 3: danger, name ann, score -1, rank -.' in say, say + # The web surface: a table's columns and cells in order, with tones. + surface = WebSurface(vega, module) + try: + for line in script[:-1]: + command, argument = line.split() + if command == 'text': surface.call('POST', '/text', bytes.fromhex(argument)) + else: surface.call('POST', '/key', argument) + board = json.loads(surface.call('GET', '/screen?rows=10')[1])['parts'][0] + finally: + surface.stop() + assert board['kind'] == 'table' and board['columns'] == ['name', 'score', 'rank'] and board['focus'] == 0, board + assert [(item['cells'], item['tone']) for item in board['items']] == [ + (['ann', '-1', '-'], 'danger'), (['범수', '3', 'gold'], 'good'), (['Zoë', '0', '-'], 'normal')], board return len(out) @@ -517,6 +647,19 @@ def robustness(vega, aup, work, rounds): result = run(vega, surface[0], module, *surface[1:], stdin=text) assert result.returncode == 0, (path, surface, result.stderr[-400:]) runs += 1 + # The web surface under random posts: every answer well formed, and a clean stop. + surface = WebSurface(vega, module) + try: + for _ in range(40): + roll = rng.random() + if roll < 0.3: surface.call('POST', '/text', rng.choice(WORDS)) + elif roll < 0.4: surface.call('POST', '/point', f'{rng.randrange(4)} {rng.randrange(4)}') + else: surface.call('POST', '/key', rng.choice(keys)) + status, body = surface.call('GET', f'/screen?rows={rng.randrange(0, 30)}&first={rng.randrange(9)},1') + assert status == 200 and isinstance(json.loads(body)['parts'], list), (path, body[:200]) + finally: + surface.stop() + runs += 1 return len(examples), runs @@ -543,6 +686,8 @@ def main(): report['table_lines'] = scores(vega, modules['scores']) report['painted_frames'], report['first_paint_bytes'], report['later_paint_bytes'] = \ painting(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30)]) + report['web_screens'] = web(vega, modules['todo'], work, range(args.seeds)) + report['browser_steps'] = browser(vega, modules['todo'], work) report['interactive_sessions'] = interactive(vega, modules['todo']) + \ interactive(vega, modules['todo'], work / 'interactive-store') report['stored_surface_lines'] = stored_surfaces(vega, modules['todo'], work, range(args.seeds)) -- cgit v1.2.3