aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile2
-rw-r--r--docs/design/001-first-component.md26
-rw-r--r--src/draw.c4
-rw-r--r--src/main.c33
-rw-r--r--src/screen.c26
-rw-r--r--src/vega.h12
-rw-r--r--src/web.c436
-rw-r--r--tests/browser.mjs91
-rw-r--r--tests/check.py145
9 files changed, 752 insertions, 23 deletions
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 <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <netinet/in.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+/* 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 <stdarg.h>
+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[] = {
+"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Vega</title><style>"
+":root{color-scheme:light dark;--fg:#1d1d1f;--bg:#fbfbfd;--line:#d2d2d7;--focus:#0a58ca;--muted:#86868b;"
+"--accent:#0071e3;--good:#1e8e3e;--warning:#b06000;--danger:#d93025}"
+"@media(prefers-color-scheme:dark){:root{--fg:#f5f5f7;--bg:#161618;--line:#3a3a3c;--focus:#4c8dff;--muted:#8e8e93;"
+"--accent:#64a8ff;--good:#5ec26a;--warning:#f0a33a;--danger:#ff6b5e}}"
+"body{margin:0;font:15px/1.5 system-ui,-apple-system,'Noto Sans KR',sans-serif;color:var(--fg);background:var(--bg)}"
+"main{max-width:760px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 12px}"
+"dl{display:grid;grid-template-columns:max-content 1fr;gap:2px 16px;margin:0 0 16px}dt{color:var(--muted)}dd{margin:0}"
+"section{margin:0 0 20px}h2{font-size:14px;font-weight:600;margin:0 0 6px}h2 span{color:var(--muted);font-weight:400}"
+"ul{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden}"
+"li,tbody tr{cursor:pointer}li{display:flex;gap:10px;padding:7px 12px;border-top:1px solid var(--line)}"
+"li:first-child{border-top:0}.label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:pre}"
+".detail{color:var(--muted);white-space:pre}.badge{font-size:12px;padding:1px 8px;border:1px solid currentColor;"
+"border-radius:10px;align-self:center}[aria-selected=true]{background:var(--focus);color:#fff}"
+"table{width:100%;border-collapse:collapse;border:1px solid var(--line)}th{text-align:left;color:var(--muted);"
+"font-weight:500}th,td{padding:6px 12px;border-top:1px solid var(--line);white-space:pre}"
+".muted{color:var(--muted)}.accent{color:var(--accent)}.good{color:var(--good)}.warning{color:var(--warning)}"
+".danger{color:var(--danger)}[aria-selected=true].muted,[aria-selected=true] *{color:#fff}"
+"form{display:flex;gap:8px;margin-top:16px}input{flex:1;font:inherit;padding:8px 10px;border:1px solid var(--line);"
+"border-radius:8px;background:transparent;color:inherit}footer{margin-top:14px;color:var(--muted);font-size:13px}"
+"kbd{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-right:4px}"
+"</style></head><body><main>",
+"<h1 id=title></h1><dl id=fields></dl><div id=parts></div>"
+"<form id=prompt hidden><input id=entry autocomplete=off><button>Enter</button></form><footer id=keys></footer></main>"
+"<script>",
+"'use strict';let screen=null,firsts=[],busy=false,again=false;const rows=Math.max(5,Math.floor((innerHeight-220)/34));"
+"const $=id=>document.getElementById(id);"
+"const el=(tag,props,...kids)=>{const e=document.createElement(tag);Object.assign(e,props||{});"
+"for(const k of kids)if(k!=null)e.append(k);return e};"
+"async function post(path,body){await fetch(path,{method:'POST',body});refresh()}"
+"async function refresh(){if(busy){again=true;return}busy=true;try{"
+"const r=await fetch('/screen?rows='+rows+'&first='+firsts.join(','));screen=await r.json();render()}"
+"finally{busy=false;if(again){again=false;refresh()}}}"
+"function render(){const s=screen;$('title').textContent=s.title?\?'';document.title=s.title?\?'Vega';"
+"$('fields').replaceChildren(...s.fields.flatMap(f=>[el('dt',{textContent:f.name}),el('dd',{textContent:f.text?\?'-'})]));"
+"firsts=s.parts.map(p=>p.first);"
+"$('parts').replaceChildren(...s.parts.map(p=>{const head=el('h2',{},p.name+' ',el('span',{textContent:'('+p.rows+')'}));"
+"const pick=row=>{if(p.pointable)post('/point',p.part+' '+row)};"
+"if(p.kind==='list'){const list=el('ul',{role:'listbox'});list.setAttribute('aria-label',p.name);"
+"for(const it of p.items){const item=el('li',{className:it.tone,onclick:()=>pick(it.row)},"
+"el('span',{className:'label',textContent:it.label?\?''}),it.detail?el('span',{className:'detail',textContent:it.detail}):null,"
+"it.badge?el('span',{className:'badge',textContent:it.badge}):null);item.setAttribute('role','option');"
+"item.setAttribute('aria-selected',it.row===p.focus);list.append(item)}"
+"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};"
+"return el('section',{},head,list)}"
+"const table=el('table',{},el('thead',{},el('tr',{},...p.columns.map(c=>el('th',{textContent:c})))));"
+"const body=el('tbody');for(const it of p.items){const tr=el('tr',{className:it.tone,onclick:()=>pick(it.row)},"
+"...it.cells.map(c=>el('td',{textContent:c?\?'-'})));tr.setAttribute('aria-selected',it.row===p.focus);body.append(tr)}"
+"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};"
+"return el('section',{},head,table)}));"
+"$('prompt').hidden=!s.prompt;if(s.prompt)$('entry').placeholder=s.prompt;"
+"$('keys').replaceChildren(...s.keys.map(k=>el('span',{},el('kbd',{textContent:k.key.replace(/^(Key|Digit)/,'')}),k.input+' ')))}"
+"$('prompt').onsubmit=e=>{e.preventDefault();const v=$('entry').value;if(v){$('entry').value='';post('/text',v)}};"
+"addEventListener('keydown',e=>{if(!screen||e.ctrlKey||e.metaKey||e.altKey)return;"
+"const typing=document.activeElement===$('entry');"
+"if(typing&&!['ArrowUp','ArrowDown','PageUp','PageDown','Escape'].includes(e.code))return;"
+"if(screen.keys.some(k=>k.key===e.code)){e.preventDefault();post('/key',e.code)}});"
+"new EventSource('/events').onmessage=refresh;refresh();"
+"</script></body></html>"};
+
+/* 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))