aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-25 15:24:05 +0100
committereunhhu <[email protected]>2026-09-25 15:24:05 +0100
commit12c377ea70c8928f96d02d0885744bd6818c10c6 (patch)
treee2ba24cb2cd61394ff84f605daa54d8c8e36a0d1 /src
parentd9dbe8a1dbdd30f3e336dc1445d852ede3de3930 (diff)
downloadvega-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.
Diffstat (limited to 'src')
-rw-r--r--src/draw.c429
-rw-r--r--src/main.c378
-rw-r--r--src/screen.c106
-rw-r--r--src/vega.h81
-rw-r--r--src/width.c70
5 files changed, 1064 insertions, 0 deletions
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;
+}