diff options
| author | eunhhu <[email protected]> | 2026-09-25 15:24:05 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-25 15:24:05 +0100 |
| commit | 12c377ea70c8928f96d02d0885744bd6818c10c6 (patch) | |
| tree | e2ba24cb2cd61394ff84f605daa54d8c8e36a0d1 | |
| parent | d9dbe8a1dbdd30f3e336dc1445d852ede3de3930 (diff) | |
| download | vega-12c377ea70c8928f96d02d0885744bd6818c10c6.tar.gz vega-12c377ea70c8928f96d02d0885744bd6818c10c6.zip | |
Add the first component and three surfaces
Vega reads a verified Pure module by convention: a table view with a
label column is a list (with optional badge, detail and tone), any other
table is a table, <table>_focus is its focused row, a title view is the
title and other views are fields. lib/vega.aup gives aup apps the tones,
which say what a row means; each surface decides how it looks or sounds.
vega tui draws the screen in a terminal, keeps the focused row in view
and evaluates only the cells on screen; vega cli prints plain lines for
pipes; vega say speaks the screen and each focus change as sentences.
Display width handles Hangul, CJK, emoji, combining marks and controls.
tests/check.py compares the CLI and NUI output with a reference model of
examples/todo.aup line for line, checks 9,600 TUI frames for scrolling,
focus, badge alignment and the number of cells evaluated, drives the
interactive TUI on a pseudo-terminal, and runs every aup example on every
surface.
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Makefile | 23 | ||||
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | docs/design/001-first-component.md | 66 | ||||
| -rw-r--r-- | examples/scores.aup | 35 | ||||
| -rw-r--r-- | examples/todo.aup | 40 | ||||
| -rw-r--r-- | lib/vega.aup | 15 | ||||
| -rw-r--r-- | src/draw.c | 429 | ||||
| -rw-r--r-- | src/main.c | 378 | ||||
| -rw-r--r-- | src/screen.c | 106 | ||||
| -rw-r--r-- | src/vega.h | 81 | ||||
| -rw-r--r-- | src/width.c | 70 | ||||
| -rw-r--r-- | tests/check.py | 325 |
13 files changed, 1583 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9b15acc --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build +# Sibling checkouts: Pure runs the modules, aup builds them. +PURE ?= ../pure/prototypes/connections +AUP ?= ../aup + +PURE_SOURCES = $(PURE)/module.c $(PURE)/module-list.c $(PURE)/module-text.c $(PURE)/store-tree.c +SOURCES = src/screen.c src/draw.c src/width.c src/main.c + +.PHONY: all check +all: $(BUILD)/vega + +$(BUILD): + mkdir -p $@ + +$(BUILD)/vega: $(SOURCES) src/vega.h $(PURE_SOURCES) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -I$(PURE) $(SOURCES) $(PURE_SOURCES) $(LDFLAGS) -lm $(LDLIBS) -o $@ + +check: $(BUILD)/vega + $(MAKE) -C $(AUP) build/aup + python3 tests/check.py --vega $(BUILD)/vega --aup $(AUP)/build/aup @@ -3,7 +3,17 @@ Vega는 **인터페이스 구현자**이며 하나의 **aup 라이브러리**다(`use vega`). 사용자가 보는 인터페이스의 규격과 고도화된 명세를 기반으로 TUI, CLI, GUI, NUI를 쉽게 만들 수 있게 한다. -상태: 저장소와 헌장만 있다. 구현은 아직 없다. +상태: 첫 컴포넌트(목록·표·포커스·톤)와 표면 셋(TUI·CLI·NUI 문장)이 동작한다. +[설계 001](docs/design/001-first-component.md). + +```sh +make # build/vega (옆의 ../pure 체크아웃을 링크) +AUP_PATH=lib ../aup/build/aup build examples/todo.aup -o todo.pmod +build/vega tui todo.pmod # 터미널. 글자 입력 후 Enter, Tab으로 키 모드, Ctrl-C로 끝 +printf 'text 6d696c6b\nkey Space\nshow\n' | build/vega cli todo.pmod +printf 'text 6d696c6b\nkey Space\nshow\n' | build/vega say todo.pmod +make check # 참조 모델 대조, 프레임 검사, 대화형 pty 검사, 견고성 +``` ## 위치 @@ -34,5 +44,6 @@ aup는 일반적인 투영과 모듈만 제공하고, 목록·표·커서·톤 � ## 열린 질문 -- NUI의 첫 범위(음성인지, 대화형 텍스트인지, 제스처인지). -- 표면별 고도화(예: GUI의 가상 스크롤, TUI의 셀 단위 갱신)를 명세의 어디에 둘지. +- NUI의 다음 범위. 지금은 화면 낭독 같은 문장이다. 음성 합성·대화형 입력·제스처 중 무엇이 먼저인지. +- 표면별 고도화(예: TUI의 셀 단위 갱신, GUI의 부드러운 스크롤)를 명세의 어디에 둘지. 가상 스크롤은 이미 + 런타임의 성질이다(보이는 칸만 계산). diff --git a/docs/design/001-first-component.md b/docs/design/001-first-component.md new file mode 100644 index 0000000..434c0fe --- /dev/null +++ b/docs/design/001-first-component.md @@ -0,0 +1,66 @@ +# 001 — 첫 컴포넌트: 목록·표·포커스·톤, 표면 셋 + +2026-09-25. 상태: 구현(`src/`, `lib/vega.aup`, `examples/`). + +## 결정 + +Vega의 명세는 **새 언어나 위젯 트리가 아니라 aup 규약**이다. 앱은 aup로 상태와 보기를 선언하고, +Vega는 검증된 Pure 모듈의 보기를 이름과 타입으로 읽어 인터페이스 부품으로 해석한다. 인터페이스용 +화면 모델을 따로 만들지 않고, 표면은 Pure에 보이는 칸만 물어 그린다. + +| 모듈에 있는 것 | Vega가 읽는 부품 | +|---|---| +| text 보기 `title` | 화면 제목 | +| `label` text 열이 있는 표 보기 | **목록**. 행마다 `label`, 선택적으로 `badge`(text), `detail`(text), `tone`(수) | +| `label` 열이 없는 표 보기 | **표**. `tone`을 뺀 모든 열이 차례로 보인다 | +| 수 보기 `<표 이름>_focus` | 그 목록·표의 포커스 행(0부터) | +| 그 밖의 수·text 보기 | **필드**. 이름과 값 | +| 키 입력과 그 핸들러 | 표면이 보여 주는 키 목록 | +| 첫 text 입력 | 입력 줄(TUI), 스크립트의 `text` 명령 | + +톤은 의미다(`lib/vega.aup`: `normal muted accent good warning danger`, `vega.when(조건, 톤)`). 색과 소리는 표면이 +정한다. 지원하지 않는 표현은 다른 의미로 바꾸지 않고 드러낸다. 색이 없는 CLI는 톤을 `(danger)`처럼 글자로 적는다. + +```text +use vega +view tasks_list = each task in tasks { + label = task.title + badge = if task.done { "done" } else if task.urgent { "urgent" } else { "" } + tone = if task.done { vega.muted } else { vega.when(task.urgent, vega.danger) } +} +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`에서 화면 전체 | + +TUI 입력: 앱에 text 입력이 있으면 처음에는 글자가 입력 줄로 가고 Enter가 확정한다(IME 확정분과 같은 의미). +Tab이 키 모드로 바꾸면 글자·숫자·Space가 `KeyA`·`Digit0`·`Space` 입력이 된다(앱이 Tab을 쓰면 Tab은 앱의 것). +화살표·Delete·Home 같은 키는 두 모드 모두 앱으로 간다. Ctrl-C나 Ctrl-Q로 끝내고 터미널을 되돌린다. + +표시 폭은 Vega가 맡는다. 한글·CJK·그림 이모지는 두 칸, 결합 문자·변이 선택자·ZWJ는 0칸, 제어 문자는 +제어 그림(U+2400대)으로 보인다. grapheme 단위 자르기는 아직 없어 ZWJ 이모지 묶음은 근사다. + +## 검증 (`make check`) + +- `examples/todo.aup`에 무작위 추가·이동·토글·긴급·삭제 스크립트를 주고, CLI 출력과 NUI 문장이 앱과 Vega 규약의 + Python 참조 모델과 줄 단위로 같다(8시드, 약 8,000줄, 빈 목록에서 실패하는 이벤트 약 200건 포함). +- TUI 프레임 9,600개(40×12, 24×9, 80×30, 16×6)에서 보이는 행 범위와 최소 스크롤, 포커스 행, 마지막 열에서 끝나는 배지, + 말줄임, 행 톤이 모델과 같다. 프레임마다 계산한 칸 수가 `제목 + 필드 + 포커스 2회 + 보이는 행 × 3`과 정확히 같다. + 2,000항목 목록도 한 프레임에 25칸만 계산한다. +- `examples/scores.aup`의 표가 표시 폭으로 정렬되고(한글·결합 문자 포함) 행 톤이 표시된다. +- 가상 터미널에서 대화형 TUI를 실제로 띄워 입력·확정·키 모드·종료·터미널 복원을 확인한다. +- aup와 Vega의 예제 11개를 세 표면에 무작위 입력으로 돌려 비정상 종료가 없다. sanitizer 빌드로도 같은 검사를 통과한다. + +## 남은 것 + +- 규약 오류를 조용히 넘기지 않기: `label`이 수인 목록, 표에 없는 `_focus` 같은 것을 `vega check`로 알린다. +- 포커스를 표면에서 바꾸기(GUI 클릭, TUI 마우스). 지금은 앱의 키 핸들러만 포커스를 옮긴다. Pure에 표의 인덱스를 + 이벤트로 바꾸는 호스트 API가 필요하다. +- 셀 단위 갱신(TUI는 매 변경마다 전체를 다시 그린다), GUI 표면, 음성 합성으로 가는 NUI. +- 문장의 언어. 지금 NUI의 틀 문장은 영어이고 값은 앱의 text 그대로다. diff --git a/examples/scores.aup b/examples/scores.aup new file mode 100644 index 0000000..14620e9 --- /dev/null +++ b/examples/scores.aup @@ -0,0 +1,35 @@ +# A table: a table view without a label column. Every column shows; a tone +# column colors a row. +use vega + +record Player { + name: text + score = 0 +} + +state players: list of Player +state at: index of players + +input join = text +input up = key ArrowUp +input down = key ArrowDown +input win = key KeyW +input lose = key KeyL + +on join { + players.append(Player { name: join }) + at = players.last +} +on up.down { at = at - 1 } +on down.down { at = at + 1 } +on win.down { at.score = at.score + 1 } +on lose.down { at.score = at.score - 1 } + +view title = "Scores" +view board = each p in players { + name = p.name + score = p.score + rank = if p.score > 2 { "gold" } else if p.score > 0 { "silver" } else { "-" } + tone = if p.score < 0 { vega.danger } else { vega.when(p.score > 2, vega.good) } +} +view board_focus = at.position diff --git a/examples/todo.aup b/examples/todo.aup new file mode 100644 index 0000000..a5ee3b0 --- /dev/null +++ b/examples/todo.aup @@ -0,0 +1,40 @@ +# A to-do list as Vega reads it: one spec, shown by the terminal (vega tui), +# printed for pipes (vega cli) and spoken as sentences (vega say). +use vega + +record Task { + title: text + done = 0 + urgent = 0 +} + +state tasks: list of Task +state at: index of tasks + +input add = text +input up = key ArrowUp +input down = key ArrowDown +input toggle = key Space +input urgent = key KeyU +input drop = key Delete + +on add { + tasks.append(Task { title: add }) + at = tasks.last +} +on up.down { at = at - 1 } +on down.down { at = at + 1 } +on toggle.down { at.done = !at.done } +on urgent.down { at.urgent = !at.urgent } +on drop.down { tasks.remove(at) } + +view title = "To do" +view left = "{tasks.count} tasks" + +# A list: label, and optionally tone, badge and detail per row. +view tasks_list = each task in tasks { + label = task.title + badge = if task.done { "done" } else if task.urgent { "urgent" } else { "" } + tone = if task.done { vega.muted } else { vega.when(task.urgent, vega.danger) } +} +view tasks_list_focus = at.position diff --git a/lib/vega.aup b/lib/vega.aup new file mode 100644 index 0000000..8e5b780 --- /dev/null +++ b/lib/vega.aup @@ -0,0 +1,15 @@ +# Vega for aup apps: `use vega` (AUP_PATH=.../vega/lib). +# +# A tone says what a value means, not how it looks. Each surface decides: +# a terminal may dim muted rows and color danger red, a screen reader may +# say "danger" before an item. Use them in a `tone` column of a list or +# table view (docs/design/001-first-component.md). +fn normal = 0 +fn muted = 1 +fn accent = 2 +fn good = 3 +fn warning = 4 +fn danger = 5 + +# A tone only when a condition holds: vega.when(task.done, vega.muted). +fn when(condition, tone) = if condition { tone } else { normal } diff --git a/src/draw.c b/src/draw.c new file mode 100644 index 0000000..cf4e227 --- /dev/null +++ b/src/draw.c @@ -0,0 +1,429 @@ +#include "vega.h" +#include <stdlib.h> +#include <string.h> + +static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", "good", "warning", "danger"}; + +/* ---- CLI ------------------------------------------------------------ */ + +/* Text for a pipe: control characters become their pictures so a value never + * breaks a line. */ +static void put_text(FILE *out, const char *text, size_t length) { + for (size_t at = 0; at < length;) { + uint32_t codepoint; + size_t size = vega_decode(text, length, at, &codepoint); + if (vega_visible(codepoint) == codepoint) fwrite(text + at, 1, size, out); + else { + uint32_t picture = vega_visible(codepoint); + fputc(0xE0 | (int)(picture >> 12), out); + fputc(0x80 | (int)(picture >> 6 & 0x3F), out); + fputc(0x80 | (int)(picture & 0x3F), out); + } + at += size; + } +} + +static void pad(FILE *out, unsigned count) { + while (count--) fputc(' ', out); +} + +static const char *const colors[VEGA_TONES] = {"", "\033[2m", "\033[1;36m", "\033[32m", "\033[33m", "\033[31m"}; + +static void tone_start(FILE *out, VegaTone tone, bool color) { + if (color && tone) fputs(colors[tone], out); +} + +static void tone_end(FILE *out, VegaTone tone, bool color) { + if (color && tone) fputs("\033[0m", out); + else if (!color && tone) fprintf(out, " (%s)", tone_names[tone]); +} + +static void print_list(FILE *out, PureApp *app, const VegaPart *part, bool color) { + char text[VEGA_TEXT]; + size_t length, rows = vega_rows(app, part), focus = vega_focus(app, part); + for (size_t row = 0; row < rows; ++row) { + VegaTone tone = vega_tone(app, part, row); + fputs(row == focus ? "> " : " ", out); + tone_start(out, tone, color); + if (vega_text(app, (uint32_t)part->label, row, text, &length)) put_text(out, text, length); + if (part->badge >= 0 && vega_text(app, (uint32_t)part->badge, row, text, &length) && length) { + fputs(" [", out); + put_text(out, text, length); + fputc(']', out); + } + if (part->detail >= 0 && vega_text(app, (uint32_t)part->detail, row, text, &length) && length) { + fputs(" - ", out); + put_text(out, text, length); + } + tone_end(out, tone, color); + fputc('\n', out); + } +} + +static void print_table(FILE *out, PureApp *app, const VegaPart *part, bool color) { + const PureModule *m = app->module; + size_t rows = vega_rows(app, part), focus = vega_focus(app, part); + unsigned widths[PURE_MODULE_VIEWS]; + char text[VEGA_TEXT]; + size_t length; + for (unsigned c = 0; c < part->column_count; ++c) { + const char *name = m->view_names[part->columns[c]]; + widths[c] = vega_text_width(name, strlen(name)); + for (size_t row = 0; row < rows; ++row) + if (vega_text(app, part->columns[c], row, text, &length)) { + unsigned width = vega_text_width(text, length); + if (width > widths[c]) widths[c] = width; + } + } + fputs(" ", out); + for (unsigned c = 0; c < part->column_count; ++c) { + const char *name = m->view_names[part->columns[c]]; + fputs(name, out); + if (c + 1 < part->column_count) pad(out, widths[c] - (unsigned)strlen(name) + 2); + } + fputc('\n', out); + for (size_t row = 0; row < rows; ++row) { + VegaTone tone = vega_tone(app, part, row); + fputs(row == focus ? "> " : " ", out); + tone_start(out, tone, color); + for (unsigned c = 0; c < part->column_count; ++c) { + bool ok = vega_text(app, part->columns[c], row, text, &length); + if (!ok) { memcpy(text, "-", 2); length = 1; } + put_text(out, text, length); + if (c + 1 < part->column_count) pad(out, widths[c] - vega_text_width(text, length) + 2); + } + tone_end(out, tone, color); + fputc('\n', out); + } +} + +void vega_print(FILE *out, PureApp *app, const VegaScreen *screen, bool color) { + const PureModule *m = app->module; + char text[VEGA_TEXT]; + size_t length; + if (screen->title >= 0 && vega_text(app, (uint32_t)screen->title, 0, text, &length)) { + if (color) fputs("\033[1m", out); + put_text(out, text, length); + if (color) fputs("\033[0m", out); + fputc('\n', out); + } + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + const char *name = m->view_names[part->view]; + if (part->kind == VEGA_FIELD) { + fprintf(out, "%s: ", name); + if (vega_text(app, part->view, 0, text, &length)) put_text(out, text, length); + else fputs("-", out); + fputc('\n', out); + continue; + } + fprintf(out, "%s (%zu)\n", name, vega_rows(app, part)); + if (part->kind == VEGA_LIST) print_list(out, app, part, color); + else print_table(out, app, part, color); + } +} + +/* ---- NUI ------------------------------------------------------------ */ + +/* Spoken text: control characters become spaces, and trailing ones go. */ +static void speak(FILE *out, const char *text, size_t length) { + while (length && ((unsigned char)text[length - 1] <= 0x20)) --length; + for (size_t i = 0; i < length; ++i) fputc((unsigned char)text[i] < 0x20 ? ' ' : text[i], out); +} + +/* "Item 2 of 3: milk, done." Tones other than normal and muted are said. */ +static void say_focus(FILE *out, PureApp *app, const VegaPart *part) { + const PureModule *m = app->module; + char text[VEGA_TEXT]; + size_t length, rows = vega_rows(app, part); + if (!rows) { fprintf(out, "%s is empty.\n", m->view_names[part->view]); return; } + size_t focus = vega_focus(app, part); + fprintf(out, "Item %zu of %zu: ", focus + 1, rows); + VegaTone tone = vega_tone(app, part, focus); + if (tone >= VEGA_ACCENT) fprintf(out, "%s, ", tone_names[tone]); + if (part->kind == VEGA_LIST) { + if (vega_text(app, (uint32_t)part->label, focus, text, &length)) speak(out, text, length); + int more[] = {part->badge, part->detail}; + for (unsigned k = 0; k < 2; ++k) + if (more[k] >= 0 && vega_text(app, (uint32_t)more[k], focus, text, &length) && length) { + fputs(", ", out); + speak(out, text, length); + } + } else { + for (unsigned c = 0; c < part->column_count; ++c) { + fprintf(out, "%s%s ", c ? ", " : "", m->view_names[part->columns[c]]); + if (vega_text(app, part->columns[c], focus, text, &length)) speak(out, text, length); + else fputs("none", out); + } + } + fputs(".\n", out); +} + +void vega_say(FILE *out, PureApp *app, const VegaScreen *screen, bool all) { + const PureModule *m = app->module; + char text[VEGA_TEXT]; + size_t length; + if (!all) { + for (unsigned i = 0; i < screen->count; ++i) + if (screen->parts[i].kind != VEGA_FIELD) { say_focus(out, app, &screen->parts[i]); return; } + return; + } + if (screen->title >= 0 && vega_text(app, (uint32_t)screen->title, 0, text, &length)) { + speak(out, text, length); + fputs(".\n", out); + } + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + const char *name = m->view_names[part->view]; + if (part->kind == VEGA_FIELD) { + fprintf(out, "%s: ", name); + if (vega_text(app, part->view, 0, text, &length)) speak(out, text, length); + else fputs("none", out); + fputs(".\n", out); + continue; + } + size_t rows = vega_rows(app, part); + fprintf(out, "%s, %zu item%s.\n", name, rows, rows == 1 ? "" : "s"); + if (rows) say_focus(out, app, part); + } +} + +/* ---- TUI ------------------------------------------------------------ */ + +bool vega_grid(VegaGrid *grid, unsigned width, unsigned height) { + size_t count = (size_t)width * height; + VegaCell *cells = calloc(count ? count : 1, sizeof(*cells)); + if (!cells) return false; + free(grid->cells); + grid->cells = cells; + grid->width = width; + grid->height = height; + return true; +} + +void vega_grid_free(VegaGrid *grid) { + free(grid->cells); + *grid = (VegaGrid){0}; +} + +typedef struct { VegaGrid *grid; unsigned row, column, end; uint8_t tone; bool focus, strong; } Pen; + +/* Write text from the pen's column up to its end; a wide character that + * does not fit is left out, and a mark joins the character before it. */ +static void write_text(Pen *pen, const char *text, size_t length) { + VegaCell *line = pen->grid->cells + (size_t)pen->row * pen->grid->width; + for (size_t at = 0; at < length;) { + uint32_t codepoint; + at += vega_decode(text, length, at, &codepoint); + codepoint = vega_visible(codepoint); + unsigned width = vega_width(codepoint); + if (!width) { + if (pen->column && !line[pen->column - 1].mark) { + unsigned base = pen->column - 1; + while (base && !line[base].ch) --base; + line[base].mark = codepoint; + } + continue; + } + if (pen->column + width > pen->end) { pen->column = pen->end; return; } + line[pen->column] = (VegaCell){codepoint, 0, pen->tone, pen->focus, pen->strong}; + if (width == 2) line[pen->column + 1] = (VegaCell){0, 0, pen->tone, pen->focus, pen->strong}; + pen->column += width; + } +} + +static void write_cstr(Pen *pen, const char *text) { write_text(pen, text, strlen(text)); } + +/* Fill the rest of the pen's span with spaces in its style (a focused row + * is highlighted across). */ +static void fill(Pen *pen) { + VegaCell *line = pen->grid->cells + (size_t)pen->row * pen->grid->width; + while (pen->column < pen->end) line[pen->column++] = (VegaCell){' ', 0, pen->tone, pen->focus, pen->strong}; +} + +/* Text cut to width with an ellipsis. */ +static void write_fit(Pen *pen, const char *text, size_t length, unsigned width) { + unsigned end = pen->end; + if (pen->column + width < end) pen->end = pen->column + width; + if (vega_text_width(text, length) > pen->end - pen->column && pen->end > pen->column) { + --pen->end; + write_text(pen, text, length); + pen->end = end; + write_cstr(pen, "\xe2\x80\xa6"); + } else { + write_text(pen, text, length); + pen->end = end; + } +} + +static const char *key_symbol(const char *key) { + static const struct { const char *key, *symbol; } symbols[] = { + {"ArrowUp", "\xe2\x86\x91"}, {"ArrowDown", "\xe2\x86\x93"}, {"ArrowLeft", "\xe2\x86\x90"}, + {"ArrowRight", "\xe2\x86\x92"}, {"Space", "Space"}, {"Enter", "Enter"}, {"Backspace", "Bksp"}, + {"Delete", "Del"}, {"Escape", "Esc"}, {"PageUp", "PgUp"}, {"PageDown", "PgDn"}}; + for (size_t i = 0; i < sizeof(symbols) / sizeof(*symbols); ++i) + if (!strcmp(symbols[i].key, key)) return symbols[i].symbol; + if (!strncmp(key, "Key", 3)) return key + 3; + if (!strncmp(key, "Digit", 5)) return key + 5; + return key; +} + +static void draw_list(VegaGrid *grid, PureApp *app, const VegaPart *part, size_t first, unsigned top, unsigned height) { + char text[VEGA_TEXT]; + size_t length, rows = vega_rows(app, part), focus = vega_focus(app, part); + for (unsigned r = 0; r < height && first + r < rows; ++r) { + size_t row = first + r; + bool focused = row == focus; + VegaTone tone = vega_tone(app, part, row); + Pen pen = {grid, top + r, 0, grid->width, (uint8_t)tone, focused, false}; + write_cstr(&pen, focused ? "> " : " "); + char badge[VEGA_TEXT]; + size_t badge_length = 0; + if (part->badge >= 0 && !vega_text(app, (uint32_t)part->badge, row, badge, &badge_length)) badge_length = 0; + unsigned badge_width = badge_length ? vega_text_width(badge, badge_length) + 2 : 0; + unsigned room = grid->width > pen.column + badge_width ? grid->width - pen.column - badge_width : 0; + if (vega_text(app, (uint32_t)part->label, row, text, &length)) write_fit(&pen, text, length, room); + if (part->detail >= 0 && vega_text(app, (uint32_t)part->detail, row, text, &length) && length) { + unsigned used = pen.column; + uint8_t saved = pen.tone; + pen.tone = pen.tone ? pen.tone : VEGA_MUTED; + write_cstr(&pen, " "); + unsigned left = grid->width > badge_width + pen.column ? grid->width - badge_width - pen.column : 0; + if (pen.column - used == 2) write_fit(&pen, text, length, left); + pen.tone = saved; + } + if (badge_length && grid->width >= badge_width) { + /* The badge ends at the last column, two cells after the label at least. */ + unsigned start = grid->width - (badge_width - 2); + if (pen.column < start) fill(&(Pen){grid, pen.row, pen.column, start, pen.tone, focused, false}); + pen.column = start > pen.column ? start : pen.column; + write_fit(&pen, badge, badge_length, badge_width - 2); + } + fill(&pen); + } +} + +static void draw_table(VegaGrid *grid, PureApp *app, const VegaPart *part, size_t first, unsigned top, unsigned height) { + const PureModule *m = app->module; + char text[VEGA_TEXT]; + size_t length, rows = vega_rows(app, part), focus = vega_focus(app, part); + unsigned visible = height ? height - 1 : 0, widths[PURE_MODULE_VIEWS], total = 2; + /* Columns as wide as their header and the rows on screen, shared out when too wide. */ + for (unsigned c = 0; c < part->column_count; ++c) { + const char *name = m->view_names[part->columns[c]]; + widths[c] = vega_text_width(name, strlen(name)); + for (unsigned r = 0; r < visible && first + r < rows; ++r) + if (vega_text(app, part->columns[c], first + r, text, &length)) { + unsigned width = vega_text_width(text, length); + if (width > widths[c]) widths[c] = width; + } + total += widths[c] + 2; + } + if (part->column_count && total > grid->width) { + unsigned share = grid->width > 2 + 2 * part->column_count ? (grid->width - 2) / part->column_count - 2 : 1; + for (unsigned c = 0; c < part->column_count; ++c) if (widths[c] > share) widths[c] = share; + } + if (!height) return; + Pen head = {grid, top, 2, grid->width, VEGA_MUTED, false, true}; + for (unsigned c = 0; c < part->column_count; ++c) { + const char *name = m->view_names[part->columns[c]]; + unsigned start = head.column; + write_fit(&head, name, strlen(name), widths[c]); + head.column = start + widths[c] + 2 < grid->width ? start + widths[c] + 2 : grid->width; + } + for (unsigned r = 0; r < visible && first + r < rows; ++r) { + size_t row = first + r; + bool focused = row == focus; + Pen pen = {grid, top + 1 + r, 0, grid->width, (uint8_t)vega_tone(app, part, row), focused, false}; + write_cstr(&pen, focused ? "> " : " "); + for (unsigned c = 0; c < part->column_count; ++c) { + unsigned start = pen.column, end = start + widths[c] + 2 < grid->width ? start + widths[c] + 2 : grid->width; + if (vega_text(app, part->columns[c], row, text, &length)) write_fit(&pen, text, length, widths[c]); + else write_cstr(&pen, "-"); + if (pen.column < end) fill(&(Pen){grid, pen.row, pen.column, end, pen.tone, focused, false}); + pen.column = end; + } + fill(&pen); + } +} + +void vega_draw(VegaGrid *grid, PureApp *app, const VegaScreen *screen, const char *prompt, size_t prompt_length, + bool typing) { + const PureModule *m = app->module; + for (size_t i = 0; i < (size_t)grid->width * grid->height; ++i) grid->cells[i] = (VegaCell){' ', 0, 0, false, false}; + grid->cursor_row = grid->cursor_column = -1; + if (!grid->width || !grid->height) return; + char text[VEGA_TEXT]; + size_t length; + unsigned row = 0, bottom = grid->height - 1 - (screen->prompt >= 0 && grid->height > 1); + if (screen->title >= 0 && row < bottom && vega_text(app, (uint32_t)screen->title, 0, text, &length)) { + Pen pen = {grid, row++, 0, grid->width, VEGA_NORMAL, false, true}; + write_fit(&pen, text, length, grid->width); + } + unsigned groups = 0; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind != VEGA_FIELD) { ++groups; continue; } + if (row >= bottom) continue; + Pen pen = {grid, row++, 0, grid->width, VEGA_MUTED, false, false}; + write_cstr(&pen, m->view_names[part->view]); + write_cstr(&pen, " "); + pen.tone = VEGA_NORMAL; + if (vega_text(app, part->view, 0, text, &length)) write_fit(&pen, text, length, grid->width); + else { pen.tone = VEGA_MUTED; write_cstr(&pen, "-"); } + } + /* Lists and tables share the rows left; each keeps its focused row in view + * and only its visible rows are evaluated. */ + unsigned left = bottom > row ? bottom - row : 0; + for (unsigned i = 0, group = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + unsigned share = left / groups + (group < left % groups); + ++group; + if (!share) continue; + size_t rows = vega_rows(app, part), focus = vega_focus(app, part); + Pen head = {grid, row, 0, grid->width, VEGA_NORMAL, false, true}; + write_cstr(&head, m->view_names[part->view]); + char count[32]; + snprintf(count, sizeof(count), " (%zu)", rows); + head.strong = false; + head.tone = VEGA_MUTED; + 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; + if (part->kind == VEGA_LIST) draw_list(grid, app, part, *first, row + 1, area); + else draw_table(grid, app, part, *first, row + 1, area); + row += share; + } + /* Keys the app answers to, then the text being typed. */ + Pen help = {grid, bottom, 0, grid->width, VEGA_MUTED, false, false}; + if (screen->prompt >= 0) write_cstr(&help, typing ? "Tab keys " : "Tab type "); + 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; + write_cstr(&help, key_symbol(m->input_keys[i])); + write_cstr(&help, " "); + write_cstr(&help, m->input_names[i]); + write_cstr(&help, " "); + } + if (screen->prompt >= 0 && grid->height > 1) { + Pen pen = {grid, grid->height - 1, 0, grid->width, typing ? VEGA_ACCENT : VEGA_MUTED, false, false}; + write_cstr(&pen, m->input_names[screen->prompt]); + write_cstr(&pen, "> "); + pen.tone = typing ? VEGA_NORMAL : VEGA_MUTED; + /* Show the end of a long entry. */ + size_t start = 0; + unsigned room = grid->width > pen.column + 1 ? grid->width - pen.column - 1 : 0; + while (start < prompt_length && vega_text_width(prompt + start, prompt_length - start) > room) { + uint32_t skip; + start += vega_decode(prompt, prompt_length, start, &skip); + } + write_text(&pen, prompt + start, prompt_length - start); + if (typing) { grid->cursor_row = (int)pen.row; grid->cursor_column = (int)pen.column; } + } +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..3c3ba0f --- /dev/null +++ b/src/main.c @@ -0,0 +1,378 @@ +#define _DEFAULT_SOURCE +#include "vega.h" +#include <poll.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <unistd.h> + +/* vega SURFACE MODULE: one module, one screen, several surfaces. + * tui interactive terminal + * tui --dump WxH the terminal's frames as text, driven by a script + * cli plain lines for pipes, driven by a script + * say sentences (the first natural-language surface), driven by a script + * Script commands, one per line: key KEYNAME | text HEX|- | type HEX | enter | + * tab | frame [N] | show. key presses and releases the input bound to that key; + * text commits text to the screen's text input; type, enter and tab act on the + * TUI's input line. */ + +static PureModule module; +static PureApp app; +static VegaScreen screen; +static char entry[VEGA_TEXT]; +static size_t entry_length; +static bool typing; + +static bool load(const char *path) { + static unsigned char bytes[1 << 17]; + FILE *file = fopen(path, "rb"); + size_t size = file ? fread(bytes, 1, sizeof(bytes), file) : 0; + bool ok = file && !ferror(file) && size < sizeof(bytes); + if (file) fclose(file); + if (!ok) { fprintf(stderr, "vega: cannot read %s\n", path); return false; } + char error[128]; + if (pure_module_load(bytes, size, &module, error, sizeof(error)) != PURE_OK) { + fprintf(stderr, "vega: %s: %s\n", path, error); + return false; + } + if (pure_app_start(&app, &module, (PureData){0}) != PURE_OK) { fprintf(stderr, "vega: cannot start\n"); return false; } + vega_screen(&screen, &module); + typing = screen.prompt >= 0; + 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 bool frames(void) { + for (uint32_t h = 0; h < module.handlers; ++h) if (module.handler[h].event == 0) return true; + return false; +} + +/* 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; +} + +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 int hex(char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; } + +static size_t unhex(const char *digits, char *out, size_t size) { + size_t length = 0; + if (!strcmp(digits, "-")) return 0; + for (size_t i = 0; digits[i] && digits[i + 1] && length < size; i += 2) + if (hex(digits[i]) >= 0 && hex(digits[i + 1]) >= 0) out[length++] = (char)(hex(digits[i]) << 4 | hex(digits[i + 1])); + return length; +} + +/* ---- terminal ------------------------------------------------------- */ + +typedef struct { char *bytes; size_t used, capacity; } Out; + +static void out_put(Out *out, const char *bytes, size_t size) { + if (out->used + size > out->capacity) { + size_t capacity = out->capacity ? out->capacity : 4096; + while (capacity < out->used + size) capacity *= 2; + char *grown = realloc(out->bytes, capacity); + if (!grown) return; + out->bytes = grown; + out->capacity = capacity; + } + memcpy(out->bytes + out->used, bytes, size); + out->used += size; +} + +static void out_codepoint(Out *out, uint32_t c) { + char b[4]; + size_t n = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; + if (n == 1) b[0] = (char)c; + else if (n == 2) { b[0] = (char)(0xC0 | c >> 6); b[1] = (char)(0x80 | (c & 0x3F)); } + else if (n == 3) { b[0] = (char)(0xE0 | c >> 12); b[1] = (char)(0x80 | (c >> 6 & 0x3F)); b[2] = (char)(0x80 | (c & 0x3F)); } + else { + b[0] = (char)(0xF0 | c >> 18); b[1] = (char)(0x80 | (c >> 12 & 0x3F)); + b[2] = (char)(0x80 | (c >> 6 & 0x3F)); b[3] = (char)(0x80 | (c & 0x3F)); + } + out_put(out, b, n); +} + +static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", "good", "warning", "danger"}; + +/* The grid as text, one line per row: '>' marks a focused row, and a row's + * tone (of its first character) follows a tab when it is not normal. */ +static void dump(const VegaGrid *grid) { + Out out = {0}; + for (unsigned r = 0; r < grid->height; ++r) { + const VegaCell *line = grid->cells + (size_t)r * grid->width; + bool focus = false; + int tone = -1; + size_t start = out.used, end; + for (unsigned c = 0; c < grid->width; ++c) { + focus = focus || line[c].focus; + if (tone < 0 && line[c].ch && line[c].ch != ' ') tone = line[c].tone; + } + out_put(&out, focus ? ">" : "|", 1); + for (unsigned c = 0; c < grid->width; ++c) { + if (!line[c].ch) continue; + out_codepoint(&out, line[c].ch); + if (line[c].mark) out_codepoint(&out, line[c].mark); + } + end = out.used; + while (end > start + 1 && out.bytes[end - 1] == ' ') --end; + out.used = end; + if (tone > 0) { out_put(&out, "\t", 1); out_put(&out, tone_names[tone], strlen(tone_names[tone])); } + out_put(&out, "\n", 1); + } + fwrite(out.bytes, 1, out.used, stdout); + free(out.bytes); +} + +static const char *const sgr_tones[VEGA_TONES] = {"", ";2", ";1;36", ";32", ";33", ";31"}; + +static void paint(const VegaGrid *grid) { + Out out = {0}; + out_put(&out, "\033[H", 3); + for (unsigned r = 0; r < grid->height; ++r) { + const VegaCell *line = grid->cells + (size_t)r * grid->width; + int style = -1; + for (unsigned c = 0; c < grid->width; ++c) { + if (!line[c].ch) continue; + int next = line[c].tone | line[c].focus << 4 | line[c].strong << 5; + if (next != style) { + char sgr[32]; + int n = snprintf(sgr, sizeof(sgr), "\033[0%s%s%sm", line[c].strong ? ";1" : "", line[c].focus ? ";7" : "", + sgr_tones[line[c].tone < VEGA_TONES ? line[c].tone : 0]); + out_put(&out, sgr, (size_t)n); + style = next; + } + out_codepoint(&out, line[c].ch); + if (line[c].mark) out_codepoint(&out, line[c].mark); + } + out_put(&out, "\033[0m", 4); + if (r + 1 < grid->height) out_put(&out, "\r\n", 2); + } + if (grid->cursor_row >= 0) { + char move[32]; + int n = snprintf(move, sizeof(move), "\033[%d;%dH\033[?25h", grid->cursor_row + 1, grid->cursor_column + 1); + out_put(&out, move, (size_t)n); + } else out_put(&out, "\033[?25l", 6); + if (write(STDOUT_FILENO, out.bytes, out.used) < 0) { /* the terminal went away; the loop ends on read */ } + free(out.bytes); +} + +static struct termios saved_terminal; +static bool raw_terminal; +static volatile sig_atomic_t resized = 1; + +static void on_resize(int signal_number) { (void)signal_number; resized = 1; } + +static void restore_terminal(void) { + if (!raw_terminal) return; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_terminal); + const char *leave = "\033[0m\033[?25h\033[?1049l"; + if (write(STDOUT_FILENO, leave, strlen(leave)) < 0) { /* nothing left to tell */ } + raw_terminal = false; +} + +static bool raw_mode(void) { + if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO) || tcgetattr(STDIN_FILENO, &saved_terminal)) return false; + struct termios t = saved_terminal; + t.c_iflag &= ~(tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + t.c_oflag &= ~(tcflag_t)OPOST; + t.c_cflag |= CS8; + t.c_lflag &= ~(tcflag_t)(ECHO | ICANON | IEXTEN | ISIG); + t.c_cc[VMIN] = 1; + t.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t)) return false; + raw_terminal = true; + atexit(restore_terminal); + const char *enter = "\033[?1049h\033[?25l"; + return write(STDOUT_FILENO, enter, strlen(enter)) >= 0; +} + +/* Whether the app takes Tab itself; otherwise Tab switches typing and keys. */ +static bool tab_switches(void) { return screen.prompt >= 0 && key_input("Tab") < 0; } + +/* One key from the terminal, as typed text or as a Pure key name. */ +static void handle_key(const char *name) { + int input; + if (!strcmp(name, "Tab") && tab_switches()) { typing = !typing; return; } + if (typing && !strcmp(name, "Enter")) { + if (entry_length && commit(entry, entry_length)) entry_length = 0; + return; + } + if (typing && !strcmp(name, "Backspace")) { + while (entry_length && ((unsigned char)entry[entry_length - 1] & 0xC0) == 0x80) --entry_length; + if (entry_length) --entry_length; + return; + } + press(name, &input); +} + +static void handle_text(const char *bytes, size_t length) { + if (typing) { + if (entry_length + length < sizeof(entry)) { memcpy(entry + entry_length, bytes, length); entry_length += length; } + return; + } + if (length != 1) return; + char c = bytes[0], name[16]; + if (c == ' ') strcpy(name, "Space"); + else if (c >= 'a' && c <= 'z') snprintf(name, sizeof(name), "Key%c", c - 'a' + 'A'); + else if (c >= 'A' && c <= 'Z') snprintf(name, sizeof(name), "Key%c", c); + else if (c >= '0' && c <= '9') snprintf(name, sizeof(name), "Digit%c", c); + else return; + handle_key(name); +} + +/* Parse terminal input: escape sequences, control keys and UTF-8 text. */ +static bool handle_input(const unsigned char *in, size_t length) { + static const struct { const char *sequence, *key; } sequences[] = { + {"[A", "ArrowUp"}, {"[B", "ArrowDown"}, {"[C", "ArrowRight"}, {"[D", "ArrowLeft"}, {"[H", "Home"}, + {"[F", "End"}, {"OA", "ArrowUp"}, {"OB", "ArrowDown"}, {"OC", "ArrowRight"}, {"OD", "ArrowLeft"}, + {"OH", "Home"}, {"OF", "End"}, {"[1~", "Home"}, {"[7~", "Home"}, {"[4~", "End"}, {"[8~", "End"}, + {"[3~", "Delete"}, {"[5~", "PageUp"}, {"[6~", "PageDown"}}; + for (size_t at = 0; at < length;) { + unsigned char c = in[at]; + if (c == 0x03 || c == 0x11) return false; /* Ctrl-C, Ctrl-Q */ + if (c == 0x1B) { + size_t matched = 0; + for (size_t i = 0; i < sizeof(sequences) / sizeof(*sequences) && !matched; ++i) { + size_t n = strlen(sequences[i].sequence); + if (at + 1 + n <= length && !memcmp(in + at + 1, sequences[i].sequence, n)) { + handle_key(sequences[i].key); + matched = 1 + n; + } + } + if (!matched) { handle_key("Escape"); matched = 1; } + at += matched; + continue; + } + if (c == '\r' || c == '\n') handle_key("Enter"); + else if (c == 0x7F || c == 0x08) handle_key("Backspace"); + else if (c == '\t') handle_key("Tab"); + else if (c >= 0x20) { + size_t n = c < 0x80 ? 1 : c < 0xE0 ? 2 : c < 0xF0 ? 3 : 4; + if (at + n > length) n = length - at; + handle_text((const char *)in + at, n); + at += n; + continue; + } + ++at; + } + return true; +} + +static int interactive(void) { + if (!raw_mode()) { fputs("vega: the tui needs a terminal (or use --dump WxH)\n", stderr); return 2; } + signal(SIGWINCH, on_resize); + VegaGrid grid = {0}; + bool ticking = frames(); + for (;;) { + if (resized) { + struct winsize size; + resized = 0; + if (!ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) && size.ws_col && size.ws_row) + vega_grid(&grid, size.ws_col, size.ws_row); + else vega_grid(&grid, 80, 24); + } + vega_draw(&grid, &app, &screen, entry, entry_length, typing); + paint(&grid); + struct pollfd wait = {STDIN_FILENO, POLLIN, 0}; + int ready = poll(&wait, 1, ticking ? 16 : -1); + if (ready == 0) { pure_app_frame(&app); continue; } + if (ready < 0) continue; /* a signal, such as a resize */ + unsigned char in[256]; + ssize_t got = read(STDIN_FILENO, in, sizeof(in)); + if (got <= 0 || !handle_input(in, (size_t)got)) break; + } + restore_terminal(); + vega_grid_free(&grid); + return 0; +} + +/* ---- scripts -------------------------------------------------------- */ + +typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI } Surface; + +static int script(Surface surface, unsigned width, unsigned height) { + static char line[1 << 16]; + VegaGrid grid = {0}; + if (surface == SURFACE_TUI && !vega_grid(&grid, width, height)) return 2; + bool color = surface == SURFACE_CLI && isatty(STDOUT_FILENO) && !getenv("NO_COLOR"); + unsigned long counted = vega_evaluations(); + while (fgets(line, sizeof(line), stdin)) { + char command[16] = "", argument[sizeof(line)] = ""; + line[strcspn(line, "\r\n")] = 0; + if (sscanf(line, "%15s %65535s", command, argument) < 1) continue; + bool acted = true, ok = true; + int input = -1; + if (!strcmp(command, "key")) { + ok = press(argument, &input); + if (input < 0) fprintf(stderr, "vega: no input answers %s\n", argument); + } else if (!strcmp(command, "text")) { + char text[VEGA_TEXT]; + ok = commit(text, unhex(argument, text, sizeof(text))); + } else if (!strcmp(command, "type")) { + entry_length += unhex(argument, entry + entry_length, sizeof(entry) - 1 - entry_length); + acted = false; + } else if (!strcmp(command, "enter")) { + handle_key("Enter"); + } else if (!strcmp(command, "tab")) { + handle_key("Tab"); + acted = false; + } else if (!strcmp(command, "frame")) { + long count = *argument ? strtol(argument, NULL, 10) : 1; + for (long i = 0; i < count && i < 600; ++i) ok = pure_app_frame(&app) == PURE_OK && ok; + } else if (!strcmp(command, "show")) { + acted = false; + if (surface == SURFACE_CLI) vega_print(stdout, &app, &screen, color); + else if (surface == SURFACE_SAY) vega_say(stdout, &app, &screen, true); + else { + vega_draw(&grid, &app, &screen, entry, entry_length, typing); + dump(&grid); + printf("cells %lu\n", vega_evaluations() - counted); + } + counted = vega_evaluations(); + } else { + fprintf(stderr, "vega: unknown command %s\n", command); + continue; + } + if (!ok && input >= 0) fprintf(stderr, "vega: %s failed\n", module.input_names[input]); + if (surface == SURFACE_SAY && acted) { + if (!ok) printf("Could not %s.\n", input >= 0 ? module.input_names[input] : "do that"); + else vega_say(stdout, &app, &screen, false); + } + fflush(stdout); + } + vega_grid_free(&grid); + return 0; +} + +int main(int argc, char **argv) { + unsigned width = 0, height = 0; + bool dumped = argc == 5 && !strcmp(argv[3], "--dump") && sscanf(argv[4], "%ux%u", &width, &height) == 2 && + width && height && width <= 1000 && height <= 1000; + if ((argc != 3 && !dumped) || (dumped && strcmp(argv[1], "tui"))) { + fputs("vega tui|cli|say MODULE.pmod\nvega tui MODULE.pmod --dump WIDTHxHEIGHT < script\n", stderr); + return 2; + } + Surface surface = !strcmp(argv[1], "cli") ? SURFACE_CLI : !strcmp(argv[1], "say") ? SURFACE_SAY : + !strcmp(argv[1], "tui") ? SURFACE_TUI : (Surface)-1; + if ((int)surface < 0) { fprintf(stderr, "vega: unknown surface %s\n", argv[1]); return 2; } + if (!load(argv[2])) return 2; + int status = surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height); + pure_app_stop(&app); + return status; +} diff --git a/src/screen.c b/src/screen.c new file mode 100644 index 0000000..8e584a5 --- /dev/null +++ b/src/screen.c @@ -0,0 +1,106 @@ +#include "vega.h" +#include "module-text.h" +#include <math.h> +#include <string.h> + +static unsigned long evaluations; + +unsigned long vega_evaluations(void) { return evaluations; } + +static int column(const PureModule *m, uint32_t table, const char *name, uint8_t type) { + int view = pure_module_column(m, table, name); + return view >= 0 && m->view_type[view] == type ? view : -1; +} + +void vega_screen(VegaScreen *screen, const PureModule *m) { + memset(screen, 0, sizeof(*screen)); + screen->module = m; + screen->title = pure_module_view(m, "title"); + if (screen->title >= 0 && m->view_type[screen->title] != PURE_VIEW_TEXT) screen->title = -1; + screen->prompt = -1; + for (uint32_t i = 0; i < m->inputs && screen->prompt < 0; ++i) + if (m->input[i].kind == PURE_KIND_TEXT) screen->prompt = (int)i; + /* A <table>_focus view belongs to its table, not to the fields. */ + bool focus_view[PURE_MODULE_VIEWS] = {false}; + for (uint32_t i = 0; i < m->views; ++i) { + if (m->view_type[i] != PURE_VIEW_TABLE) continue; + char name[PURE_MODULE_NAME + 8]; + snprintf(name, sizeof(name), "%s_focus", m->view_names[i]); + int focus = pure_module_view(m, name); + if (focus >= 0 && m->view_type[focus] == PURE_VIEW_NUM) focus_view[focus] = true; + } + for (uint32_t i = 0; i < m->views && screen->count < VEGA_PARTS; ++i) { + if (m->view_table[i] || (int)i == screen->title || focus_view[i]) continue; + VegaPart *part = &screen->parts[screen->count++]; + *part = (VegaPart){.kind = VEGA_FIELD, .view = i, .focus = -1, .label = -1, .tone = -1, .detail = -1, .badge = -1}; + if (m->view_type[i] != PURE_VIEW_TABLE) continue; + char name[PURE_MODULE_NAME + 8]; + snprintf(name, sizeof(name), "%s_focus", m->view_names[i]); + part->focus = pure_module_view(m, name); + if (part->focus >= 0 && !focus_view[part->focus]) part->focus = -1; + part->label = column(m, i, "label", PURE_VIEW_TEXT); + part->tone = column(m, i, "tone", PURE_VIEW_NUM); + part->kind = part->label >= 0 ? VEGA_LIST : VEGA_TABLE; + if (part->kind == VEGA_LIST) { + part->detail = column(m, i, "detail", PURE_VIEW_TEXT); + part->badge = column(m, i, "badge", PURE_VIEW_TEXT); + } + for (uint32_t k = 0; k < m->views; ++k) + if (m->view_table[k] == i + 1 && (int)k != part->tone) part->columns[part->column_count++] = k; + } +} + +typedef struct { char *out; size_t used; } Collect; + +static bool collect(void *context, PureSpan span) { + Collect *c = context; + size_t room = VEGA_TEXT - 1 - c->used; + size_t take = span.size < room ? span.size : room; + /* Never cut a character: back off to a UTF-8 boundary. */ + if (take < span.size) while (take && (span.data[take] & 0xC0) == 0x80) --take; + if (take) memcpy(c->out + c->used, span.data, take); + c->used += take; + return true; +} + +bool vega_text(PureApp *app, uint32_t view, size_t row, char out[VEGA_TEXT], size_t *length) { + const PureModule *m = app->module; + bool column = m->view_table[view] != 0; + ++evaluations; + *length = 0; + out[0] = 0; + if (m->view_type[view] == PURE_VIEW_NUM) { + double value; + if (!(column ? pure_app_cell(app, view, row, &value) : pure_app_view(app, view, &value))) return false; + *length = pure_format_number(value, out); + out[*length] = 0; + return true; + } + Collect c = {out, 0}; + PureResult result = column ? pure_app_cell_text(app, view, row, collect, &c) : pure_app_view_text(app, view, collect, &c); + out[c.used] = 0; + *length = c.used; + return result == PURE_OK; +} + +bool vega_number(PureApp *app, uint32_t view, size_t row, double *out) { + ++evaluations; + return app->module->view_table[view] ? pure_app_cell(app, view, row, out) : pure_app_view(app, view, out); +} + +size_t vega_rows(const PureApp *app, const VegaPart *part) { + return part->kind == VEGA_FIELD ? 0 : pure_app_rows(app, part->view); +} + +size_t vega_focus(PureApp *app, const VegaPart *part) { + size_t rows = vega_rows(app, part); + double focus; + if (part->focus < 0 || !rows || !vega_number(app, (uint32_t)part->focus, 0, &focus) || !(focus >= 0)) return 0; + return focus >= (double)(rows - 1) ? rows - 1 : (size_t)focus; +} + +VegaTone vega_tone(PureApp *app, const VegaPart *part, size_t row) { + double tone; + if (part->tone < 0 || !vega_number(app, (uint32_t)part->tone, row, &tone)) return VEGA_NORMAL; + return tone >= 0 && tone < VEGA_TONES && tone == floor(tone) ? (VegaTone)tone : VEGA_NORMAL; +} diff --git a/src/vega.h b/src/vega.h new file mode 100644 index 0000000..40aecaa --- /dev/null +++ b/src/vega.h @@ -0,0 +1,81 @@ +#ifndef VEGA_H +#define VEGA_H +#include "module.h" +#include <stdio.h> + +/* Vega reads a verified Pure module by convention (docs/design/001-first-component.md): + * - a text view named `title` is the screen's title; + * - a table view with a text column `label` is a list; any other table is a + * table. A number column `tone` gives a row its tone; text columns `detail` + * and `badge` add to a list row; + * - a number view named `<table>_focus` is the focused row of that table; + * - every other number or text view is a field. + * A tone says what a value means (lib/vega.aup); each surface decides how it + * looks or sounds. Surfaces evaluate only the cells they present. */ +enum { VEGA_PARTS = 64, VEGA_TEXT = 1024 }; +typedef enum { VEGA_NORMAL, VEGA_MUTED, VEGA_ACCENT, VEGA_GOOD, VEGA_WARNING, VEGA_DANGER, VEGA_TONES } VegaTone; +typedef enum { VEGA_FIELD, VEGA_LIST, VEGA_TABLE } VegaKind; + +typedef struct { + VegaKind kind; + uint32_t view; + int focus; /* number view with the focused row, or -1 */ + int label, tone, detail, badge; /* columns, or -1 */ + uint32_t columns[PURE_MODULE_VIEWS]; /* a table's shown columns, in order */ + unsigned column_count; +} VegaPart; + +typedef struct { + const PureModule *module; + int title; /* text view, or -1 */ + int prompt; /* the first text input, or -1 */ + VegaPart parts[VEGA_PARTS]; + unsigned count; +} VegaScreen; + +void vega_screen(VegaScreen *, const PureModule *); + +/* A view (row ignored) or a column at a row as text; numbers in Pure's + * shortest form. false when it has no value (for example a missing item). */ +bool vega_text(PureApp *, uint32_t view, size_t row, char out[VEGA_TEXT], size_t *length); +bool vega_number(PureApp *, uint32_t view, size_t row, double *out); +size_t vega_rows(const PureApp *, const VegaPart *); +/* The focused row, clamped to the rows; 0 without a focus view. */ +size_t vega_focus(PureApp *, const VegaPart *); +VegaTone vega_tone(PureApp *, const VegaPart *, size_t row); +/* Values evaluated so far, for checking that surfaces stay lazy. */ +unsigned long vega_evaluations(void); + +/* Display cells of a code point in a terminal: 0 for combining and + * zero-width characters, 2 for East Asian wide and emoji, else 1. */ +unsigned vega_width(uint32_t codepoint); +/* The code point at text[at] (valid UTF-8) and the bytes it takes. */ +size_t vega_decode(const char *text, size_t length, size_t at, uint32_t *codepoint); +/* A control character shown as its control picture (U+2400 block), else itself. */ +uint32_t vega_visible(uint32_t codepoint); +unsigned vega_text_width(const char *text, size_t length); + +/* CLI: everything, as plain lines for pipes; color only when asked. */ +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); + +/* 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. */ +typedef struct { + uint32_t ch, mark; /* a character and at most one combining mark; ch 0 continues a wide one */ + uint8_t tone; + bool focus, strong; +} VegaCell; +typedef struct { + unsigned width, height; + VegaCell *cells; + size_t first[VEGA_PARTS]; + int cursor_row, cursor_column; /* where the terminal cursor goes, or -1 */ +} VegaGrid; +bool vega_grid(VegaGrid *, unsigned width, unsigned height); +void vega_grid_free(VegaGrid *); +/* prompt is the text being typed for the screen's text input; typing says + * whether keys go to it. */ +void vega_draw(VegaGrid *, PureApp *, const VegaScreen *, const char *prompt, size_t prompt_length, bool typing); +#endif diff --git a/src/width.c b/src/width.c new file mode 100644 index 0000000..1e893f6 --- /dev/null +++ b/src/width.c @@ -0,0 +1,70 @@ +#include "vega.h" + +typedef struct { uint32_t first, last; } Range; + +/* Zero-width: combining marks, Hangul vowel and final jamo, joiners, + * variation selectors, format characters. */ +static const Range zero[] = { + {0x0300, 0x036F}, {0x0483, 0x0489}, {0x0591, 0x05BD}, {0x0610, 0x061A}, {0x064B, 0x065F}, {0x0E31, 0x0E31}, + {0x0E34, 0x0E3A}, {0x0E47, 0x0E4E}, {0x1160, 0x11FF}, {0x1AB0, 0x1AFF}, {0x1DC0, 0x1DFF}, {0x200B, 0x200F}, + {0x202A, 0x202E}, {0x2060, 0x2064}, {0x20D0, 0x20FF}, {0x302A, 0x302F}, {0x3099, 0x309A}, {0xD7B0, 0xD7FF}, + {0xFE00, 0xFE0F}, {0xFE20, 0xFE2F}, {0xFEFF, 0xFEFF}, {0x1F3FB, 0x1F3FF}, {0xE0000, 0xE0FFF}, +}; + +/* East Asian Wide and Fullwidth, and emoji shown as pictures. */ +static const Range wide[] = { + {0x1100, 0x115F}, {0x231A, 0x231B}, {0x2329, 0x232A}, {0x23E9, 0x23EC}, {0x23F0, 0x23F0}, {0x23F3, 0x23F3}, + {0x25FD, 0x25FE}, {0x2614, 0x2615}, {0x2648, 0x2653}, {0x267F, 0x267F}, {0x2693, 0x2693}, {0x26A1, 0x26A1}, + {0x26AA, 0x26AB}, {0x26BD, 0x26BE}, {0x26C4, 0x26C5}, {0x26CE, 0x26CE}, {0x26D4, 0x26D4}, {0x26EA, 0x26EA}, + {0x26F2, 0x26F3}, {0x26F5, 0x26F5}, {0x26FA, 0x26FA}, {0x26FD, 0x26FD}, {0x2705, 0x2705}, {0x270A, 0x270B}, + {0x2728, 0x2728}, {0x274C, 0x274C}, {0x274E, 0x274E}, {0x2753, 0x2755}, {0x2757, 0x2757}, {0x2795, 0x2797}, + {0x27B0, 0x27B0}, {0x27BF, 0x27BF}, {0x2B1B, 0x2B1C}, {0x2B50, 0x2B50}, {0x2B55, 0x2B55}, {0x2E80, 0x303E}, + {0x3041, 0x33FF}, {0x3400, 0x4DBF}, {0x4E00, 0x9FFF}, {0xA000, 0xA4CF}, {0xA960, 0xA97F}, {0xAC00, 0xD7A3}, + {0xF900, 0xFAFF}, {0xFE10, 0xFE19}, {0xFE30, 0xFE6F}, {0xFF00, 0xFF60}, {0xFFE0, 0xFFE6}, {0x16FE0, 0x16FE4}, + {0x17000, 0x18CFF}, {0x1B000, 0x1B2FF}, {0x1F004, 0x1F004}, {0x1F0CF, 0x1F0CF}, {0x1F18E, 0x1F18E}, + {0x1F191, 0x1F19A}, {0x1F200, 0x1F251}, {0x1F300, 0x1F64F}, {0x1F680, 0x1F6FF}, {0x1F7E0, 0x1F7EB}, + {0x1F90C, 0x1F9FF}, {0x1FA70, 0x1FAFF}, {0x20000, 0x2FFFD}, {0x30000, 0x3FFFD}, +}; + +static bool in(const Range *ranges, size_t count, uint32_t codepoint) { + size_t low = 0, high = count; + while (low < high) { + size_t middle = (low + high) / 2; + if (codepoint < ranges[middle].first) high = middle; + else if (codepoint > ranges[middle].last) low = middle + 1; + else return true; + } + return false; +} + +unsigned vega_width(uint32_t codepoint) { + if (in(zero, sizeof(zero) / sizeof(*zero), codepoint)) return 0; + return in(wide, sizeof(wide) / sizeof(*wide), codepoint) ? 2 : 1; +} + +uint32_t vega_visible(uint32_t codepoint) { + if (codepoint < 0x20) return 0x2400 + codepoint; + if (codepoint == 0x7F) return 0x2421; + if (codepoint >= 0x80 && codepoint < 0xA0) return 0xFFFD; + return codepoint; +} + +size_t vega_decode(const char *text, size_t length, size_t at, uint32_t *codepoint) { + const unsigned char *p = (const unsigned char *)text + at; + size_t left = length - at, need = p[0] < 0x80 ? 1 : p[0] < 0xE0 ? 2 : p[0] < 0xF0 ? 3 : 4; + if (need > left) { *codepoint = 0xFFFD; return left; } + uint32_t value = need == 1 ? p[0] : need == 2 ? p[0] & 0x1Fu : need == 3 ? p[0] & 0x0Fu : p[0] & 0x07u; + for (size_t i = 1; i < need; ++i) value = value << 6 | (p[i] & 0x3Fu); + *codepoint = value; + return need; +} + +unsigned vega_text_width(const char *text, size_t length) { + unsigned width = 0; + for (size_t at = 0; at < length;) { + uint32_t codepoint; + at += vega_decode(text, length, at, &codepoint); + width += vega_width(vega_visible(codepoint)); + } + return width; +} diff --git a/tests/check.py b/tests/check.py new file mode 100644 index 0000000..2d01c98 --- /dev/null +++ b/tests/check.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Vega checks: one aup spec, three surfaces. + +1. CLI and NUI: examples/todo.aup under random adds, moves, toggles, urgent + marks and removals prints exactly what a reference Python model of the app + and of Vega's conventions says (lines for pipes, sentences for speech), + including failed events on an empty list. +2. TUI: the same runs drawn at several sizes keep the focused row on screen + with the least scrolling, show the rows around it in order, right-align + badges by display width (Hangul, CJK and emoji take two cells, combining + marks none), and evaluate only the cells on screen: a frame costs the + title, the field, the focus and three cells per visible row, however long + the list is. +3. Tables: examples/scores.aup aligns its columns by display width and marks + row tones. +4. Every aup example runs on every surface under random keys without a crash. +""" +import argparse +import json +import os +import random +import subprocess +import sys +import tempfile +import unicodedata +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent + + +def run(*args, stdin='', env=None): + return subprocess.run([str(a) for a in args], input=stdin, capture_output=True, text=True, timeout=120, + env=env) + + +def width(text): + total = 0 + for ch in text: + if unicodedata.combining(ch) or ch in '️': + continue + total += 2 if unicodedata.east_asian_width(ch) in 'WF' else 1 + return total + + +def pictures(text): + """Control characters as Vega shows them.""" + return ''.join(chr(0x2400 + ord(c)) if ord(c) < 0x20 else '␡' if c == '\x7f' else c for c in text) + + +class Todo: + """examples/todo.aup and Vega's reading of it.""" + + def __init__(self): + self.tasks, self.at = [], 0 + + def event(self, name, text=None): + """Returns False when the event fails (a missing item).""" + if name == 'add': + self.tasks.append({'title': text, 'done': 0, 'urgent': 0}) + self.at = len(self.tasks) - 1 + return True + last = max(len(self.tasks) - 1, 0) + if name in ('toggle', 'urgent', 'drop') and not self.tasks: + return False + if name == 'up': self.at = max(self.at - 1, 0) + elif name == 'down': self.at = min(self.at + 1, last) + elif name == 'toggle': self.tasks[self.at]['done'] = int(not self.tasks[self.at]['done']) + elif name == 'urgent': self.tasks[self.at]['urgent'] = int(not self.tasks[self.at]['urgent']) + elif name == 'drop': + del self.tasks[self.at] + self.at = min(self.at, max(len(self.tasks) - 1, 0)) + return True + + @staticmethod + def badge(task): + return 'done' if task['done'] else 'urgent' if task['urgent'] else '' + + @staticmethod + def tone(task): + return 'muted' if task['done'] else 'danger' if task['urgent'] else 'normal' + + def cli(self): + lines = ['To do', f'left: {len(self.tasks)} tasks', f'tasks_list ({len(self.tasks)})'] + for i, task in enumerate(self.tasks): + line = ('> ' if i == self.at else ' ') + pictures(task['title']) + if self.badge(task): + line += f' [{self.badge(task)}]' + if self.tone(task) != 'normal': + line += f' ({self.tone(task)})' + lines.append(line) + return lines + + @staticmethod + def spoken(text): + text = text.rstrip(''.join(chr(c) for c in range(0x21))) + return ''.join(' ' if ord(c) < 0x20 else c for c in text) + + def focus(self): + if not self.tasks: + return 'tasks_list is empty.' + task = self.tasks[self.at] + words = [self.spoken(task['title'])] + ([self.badge(task)] if self.badge(task) else []) + tone = 'danger, ' if self.tone(task) == 'danger' else '' + return f'Item {self.at + 1} of {len(self.tasks)}: {tone}' + ', '.join(words) + '.' + + def say(self): + n = len(self.tasks) + lines = ['To do.', f'left: {n} tasks.', f'tasks_list, {n} item{"" if n == 1 else "s"}.'] + return lines + ([self.focus()] if n else []) + + +KEYS = {'up': 'ArrowUp', 'down': 'ArrowDown', 'toggle': 'Space', 'urgent': 'KeyU', 'drop': 'Delete'} +WORDS = ['milk', '우유 사기', 'call 엄마', '日本語', '😀 party', 'café', 'tab\there', 'x' * 60, '', 'a\nb', + 'ok'] + + +def cli_and_say(vega, module, seeds, steps=300): + compared = failures = 0 + for seed in seeds: + rng = random.Random(seed) + model, script, expected_cli, expected_say = Todo(), [], [], [] + for _ in range(steps): + if rng.random() < 0.25: + word = rng.choice(WORDS) + script.append('text ' + (word.encode().hex() or '-')) + ok, name = model.event('add', word), 'add' + else: + name = rng.choice(list(KEYS) + ['drop']) + script.append(f'key {KEYS[name]}') + ok = model.event(name) + expected_say.append(model.focus() if ok else f'Could not {name}.') + failures += not ok + if rng.random() < 0.2: + script.append('show') + expected_cli += model.cli() + expected_say += model.say() + text = '\n'.join(script) + '\n' + cli = run(vega, 'cli', module, stdin=text) + assert cli.returncode == 0 and cli.stdout.splitlines() == expected_cli, \ + (seed, [(a, b) for a, b in zip(cli.stdout.splitlines(), expected_cli) if a != b][:3]) + say = run(vega, 'say', module, stdin=text) + assert say.returncode == 0 and say.stdout.splitlines() == expected_say, \ + (seed, [(a, b) for a, b in zip(say.stdout.splitlines(), expected_say) if a != b][:3]) + compared += len(expected_cli) + len(expected_say) + return compared, failures + + +def frames(output, height): + lines = output.splitlines() + for start in range(0, len(lines), height + 1): + frame, cells = lines[start:start + height], lines[start + height] + assert len(frame) == height and cells.startswith('cells '), lines[start:start + height + 1] + yield frame, int(cells.split()[1]) + + +def tui(vega, module, seeds, sizes, steps=300): + """Each frame against the model: which rows show, which one is focused, + where badges end, and how many cells were evaluated.""" + checked = 0 + for columns, rows in sizes: + area = rows - 5 # title, field, list header, key bar, input line + for seed in seeds: + rng = random.Random(seed * 7 + columns) + model, script, states = Todo(), [], [] + for _ in range(steps): + if rng.random() < 0.3: + word = rng.choice(WORDS) + script.append('text ' + (word.encode().hex() or '-')) + model.event('add', word) + else: + name = rng.choice(list(KEYS)) + script.append(f'key {KEYS[name]}') + model.event(name) + script.append('show') + states.append(([dict(t) for t in model.tasks], model.at)) + result = run(vega, 'tui', module, '--dump', f'{columns}x{rows}', stdin='\n'.join(script) + '\n') + assert result.returncode == 0, result.stderr + first = 0 + for (frame, cells), (tasks, at) in zip(frames(result.stdout, rows), states): + n = len(tasks) + assert frame[0] == '|To do' and frame[2].startswith(f'|tasks_list ({n})'), frame[:3] + if at < first: first = at + if at >= first + area: first = at - area + 1 + if first + area > n: first = max(n - area, 0) + shown = frame[3:3 + area] + visible = tasks[first:first + area] + for i, line in enumerate(shown): + if i >= len(visible): + assert line == '|', (seed, line) + continue + task, focused = visible[i], first + i == at + body = (line[1:].split('\t')[0] + ' ')[:max(2, len(line[1:].split('\t')[0]))] # trailing spaces are trimmed + assert line[0] == ('>' if focused else '|') and body.startswith('> ' if focused else ' '), (seed, i, line) + label = pictures(task['title']) + badge = Todo.badge(task) + if badge: # the badge ends at the last column + assert body.endswith(badge) and width(body) == columns, (seed, body, width(body)) + if width(label) + 2 + (len(badge) + 2 if badge else 0) <= columns: + assert body[2:].startswith(label), (seed, body, label) + else: + assert '…' in body, (seed, body) + tone = Todo.tone(task) + assert (line.split('\t')[1] if '\t' in line else 'normal') == tone, (seed, line, tone) + assert cells == 2 + (2 + 3 * len(visible) if n else 0), (seed, cells, len(visible)) + checked += 1 + # A long list costs what its screen shows: 2,000 tasks, 7 visible rows. + script = ['text ' + f'task {i}'.encode().hex() for i in range(2000)] + ['key ArrowUp'] * 3 + ['show'] + frame, cells = next(frames(run(vega, 'tui', module, '--dump', '40x12', stdin='\n'.join(script) + '\n').stdout, 12)) + assert cells == 2 + 2 + 3 * 7 and frame[9] == '>> task 1996' and frame[3] == '| task 1990', (cells, frame) + return checked + + +def scores(vega, module): + joins = ['ann', '범수', 'Zoë'] + script = [f'text {name.encode().hex()}' for name in joins] + script += ['key ArrowUp', 'key KeyW', 'key KeyW', 'key KeyW', 'key ArrowUp', 'key KeyL', 'show'] + out = run(vega, 'cli', module, stdin='\n'.join(script) + '\n').stdout.splitlines() + assert out == ['Scores', 'board (3)', + ' name score rank', + '> ann -1 - (danger)', + ' 범수 3 gold (good)', + ' Zoë 0 -'], out + tui = run(vega, 'tui', module, '--dump', '30x8', stdin='\n'.join(script) + '\n').stdout.splitlines() + assert tui[1:5] == ['|board (3)', '| name score rank\tmuted', '>> ann -1 -\tdanger', + '| 범수 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 + return len(out) + + +def interactive(vega, module): + """The terminal surface on a real pseudo-terminal: typed text commits + with Enter, arrow keys and letters reach the app after Tab, Ctrl-C leaves + and restores the terminal.""" + import fcntl, pty, select, struct, termios, time + pid, fd = pty.fork() + if pid == 0: + os.execv(str(vega), [str(vega), 'tui', str(module)]) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 10, 40, 0, 0)) + os.kill(pid, 28) # SIGWINCH: take the size + + def read_for(seconds): + data, end = b'', time.time() + seconds + while time.time() < end: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + data += os.read(fd, 65536) + except OSError: + break + return data + + screen = read_for(0.3) + for keys in (b'milk\r', 'b\u00e9ton\r'.encode(), b'\t', b'\x1b[A', b' ', b'u'): + os.write(fd, keys) + screen += read_for(0.2) + os.write(fd, b'\x03') + screen += read_for(0.3) + _, status = os.waitpid(pid, 0) + os.close(fd) + text = screen.decode(errors='replace') + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, status + assert '\x1b[?1049h' in text and text.rstrip().endswith('\x1b[?1049l'), text[-80:] + assert 'milk' in text and 'béton' in text and '(2)' in text and 'done' in text, text[-400:] + return 1 + + +def robustness(vega, aup, work, rounds): + """Every aup example, every surface, random keys and text: never a crash.""" + examples = sorted((ROOT.parent / 'aup' / 'examples').glob('*.aup')) + sorted((ROOT / 'examples').glob('*.aup')) + keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Enter', 'Delete', 'Backspace', 'Home', + 'End', 'KeyD', 'KeyS', 'KeyU', 'KeyW', 'KeyL', 'KeyM'] + env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) + runs = 0 + for path in examples: + module = work / (path.stem + '.pmod') + built = run(aup, 'build', path, '-o', module, env=env) + assert built.returncode == 0, (path, built.stderr) + rng = random.Random(path.stem) + for _ in range(rounds): + script = [] + for _ in range(60): + roll = rng.random() + if roll < 0.2: script.append('text ' + rng.choice(WORDS).encode().hex()) + elif roll < 0.3: script.append('show') + elif roll < 0.35: script.append('frame 3') + elif roll < 0.4: script.append(rng.choice(['type 61', 'enter', 'tab'])) + else: script.append('key ' + rng.choice(keys)) + text = '\n'.join(script) + '\nshow\n' + for surface in (['cli'], ['say'], ['tui', '--dump', rng.choice(['80x24', '12x5', '1x1', '200x3'])]): + result = run(vega, surface[0], module, *surface[1:], stdin=text) + assert result.returncode == 0, (path, surface, result.stderr[-400:]) + runs += 1 + return len(examples), runs + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--vega', type=Path, required=True) + parser.add_argument('--aup', type=Path, required=True) + parser.add_argument('--seeds', type=int, default=8) + parser.add_argument('--rounds', type=int, default=3) + args = parser.parse_args() + vega, aup = args.vega.resolve(), args.aup.resolve() + report = {} + with tempfile.TemporaryDirectory(prefix='vega-check-') as directory: + work = Path(directory) + env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) + modules = {} + for name in ('todo', 'scores'): + modules[name] = work / f'{name}.pmod' + built = run(aup, 'build', ROOT / 'examples' / f'{name}.aup', '-o', modules[name], env=env) + assert built.returncode == 0, built.stderr + report['cli_and_say_lines'], report['failed_events_spoken'] = \ + cli_and_say(vega, modules['todo'], range(args.seeds)) + report['tui_frames'] = tui(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30), (16, 6)]) + report['table_lines'] = scores(vega, modules['scores']) + report['interactive_sessions'] = interactive(vega, modules['todo']) + report['examples'], report['robustness_runs'] = robustness(vega, aup, work, args.rounds) + print(json.dumps(report)) + + +if __name__ == '__main__': + sys.exit(main()) |
