diff options
Diffstat (limited to 'surfaces')
| -rw-r--r-- | surfaces/draw.c | 456 | ||||
| -rw-r--r-- | surfaces/main.c | 604 | ||||
| -rw-r--r-- | surfaces/screen.c | 229 | ||||
| -rw-r--r-- | surfaces/vega.h | 124 | ||||
| -rw-r--r-- | surfaces/web.c | 453 | ||||
| -rw-r--r-- | surfaces/width.c | 70 |
6 files changed, 1936 insertions, 0 deletions
diff --git a/surfaces/draw.c b/surfaces/draw.c new file mode 100644 index 0000000..31188c7 --- /dev/null +++ b/surfaces/draw.c @@ -0,0 +1,456 @@ +#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); + if (focus >= rows) { fprintf(out, "%s: none of %zu focused.\n", m->view_names[part->view], rows); return; } + 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) { + /* The focused item where a list shows it, else the first list. */ + const VegaPart *first = NULL; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + if (!first) first = part; + if (vega_focus(app, part) < vega_rows(app, part)) { say_focus(out, app, part); return; } + } + if (first) say_focus(out, app, first); + 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)), *shown = calloc(count ? count : 1, sizeof(*shown)); + struct VegaRow *rows = calloc(height ? height : 1, sizeof(*rows)); + if (!cells || !shown || !rows) { free(cells); free(shown); free(rows); return false; } + for (unsigned r = 0; r < height; ++r) rows[r].part = -1; + free(grid->cells); + free(grid->shown); + free(grid->rows); + grid->cells = cells; + grid->shown = shown; + grid->rows = rows; + grid->painted = false; + grid->width = width; + grid->height = height; + return true; +} + +void vega_grid_free(VegaGrid *grid) { + free(grid->cells); + free(grid->shown); + free(grid->rows); + *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, int index, 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; + grid->rows[top + r] = (struct VegaRow){index, row}; + 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, int index, 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; + grid->rows[top + 1 + r] = (struct VegaRow){index, row}; + 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); + } +} + +bool vega_hit(const VegaGrid *grid, unsigned line, unsigned *part, size_t *item) { + if (line >= grid->height || grid->rows[line].part < 0) return false; + *part = (unsigned)grid->rows[line].part; + *item = grid->rows[line].item; + return true; +} + +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}; + for (unsigned r = 0; r < grid->height; ++r) grid->rows[r].part = -1; + 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]; + *first = vega_scroll(*first, focus, visible, rows); + if (part->kind == VEGA_LIST) draw_list(grid, app, part, (int)i, *first, row + 1, area); + else draw_table(grid, app, part, (int)i, *first, row + 1, area); + row += share; + } + /* 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/surfaces/main.c b/surfaces/main.c new file mode 100644 index 0000000..82131ab --- /dev/null +++ b/surfaces/main.c @@ -0,0 +1,604 @@ +#define _DEFAULT_SOURCE +#include "vega.h" +#include "module-store.h" +#include "store.h" +#include <poll.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> + +/* vega SURFACE MODULE [--store PATH]: one module, one screen, several surfaces. + * check what the module seems to mean but does not meet (exit 1) + * tui interactive terminal + * tui --dump WxH the terminal's frames as text, driven by a script + * tui --paint WxH the bytes each frame sends to the terminal, driven by a script + * cli plain lines for pipes, driven by a script + * say sentences (the first natural-language surface), driven by a script + * web [--listen ADDRESS] [--port N] + * a web page (a GUI), on 127.0.0.1 unless another address is given + * With --store the app's states live in that tree store: the app continues + * where it was, whichever surface runs it. With --watch (tui, web) a module + * written again replaces the running one and states keep their values by + * name: aup watch app.aup -o app.pmod beside it makes a saved edit show at once. + * Script commands, one per line: key KEYNAME | text HEX|- | type HEX | enter | + * tab | frame [N] | point VIEW ROW | click LINE COLUMN | time MS | tick TIMER | swap MODULE | 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; point focuses a row + * (0 first) of a list or table; click is a mouse click on the last shown TUI + * frame (1 first, as terminals report it); time sets the time the next + * events see (live surfaces use the clock) and tick ticks a timer (live + * surfaces tick them on time); swap replaces the module as --watch does. */ + +static PureModule modules[2]; /* the running one (app.module) and the next */ +static unsigned current; +static PureApp app; +static VegaScreen screen; +static VegaGrid *drawn; /* the TUI's last frame, which a click lands on */ +static char entry[VEGA_TEXT]; +static size_t entry_length; +static bool typing; + +/* The module file last read, and a digest of its bytes. A loaded module + * keeps nothing of them, so one buffer, grown to the largest, serves every load. */ +static unsigned char *module_bytes; +static size_t module_capacity; +static uint64_t module_digest; + +/* Read a module file whole into module_bytes: its size, or -1. */ +static long read_module(const char *path) { + FILE *file = fopen(path, "rb"); + size_t size = 0; + bool ok = file != NULL; + while (ok && !feof(file)) { + if (size == module_capacity) { + size_t capacity = module_capacity ? module_capacity * 2 : 1 << 16; + unsigned char *grown = capacity <= (8u << 20) ? realloc(module_bytes, capacity) : NULL; + if (!grown) { ok = false; break; } + module_bytes = grown; + module_capacity = capacity; + } + size += fread(module_bytes + size, 1, module_capacity - size, file); + ok = !ferror(file); + } + if (file) fclose(file); + if (!ok) return -1; + uint64_t h = UINT64_C(0xcbf29ce484222325) ^ size; + for (size_t i = 0; i < size; ++i) h = (h ^ module_bytes[i]) * UINT64_C(0x100000001b3); + module_digest = h; + return (long)size; +} + +/* Decode the module that read_module read from path. */ +static bool decode(const char *path, long size, PureModule *module) { + if (size < 0) { fprintf(stderr, "vega: cannot read %s\n", path); return false; } + char error[128]; + if (pure_module_load(module_bytes, (size_t)size, module, error, sizeof(error)) != PURE_OK) { + fprintf(stderr, "vega: %s: %s\n", path, error); + return false; + } + return true; +} + +static bool load_module(const char *path, PureModule *module) { return decode(path, read_module(path), module); } + +static void print_finding(void *path, const char *name, const char *message) { + printf("%s: %s: %s\n", (const char *)path, name, message); +} + +static bool load(const char *path, const char *store) { + if (!load_module(path, &modules[current])) return false; + if (store && pure_store_init(store) != PURE_OK) { fprintf(stderr, "vega: cannot open store %s\n", store); return false; } + PureResult started = pure_app_start(&app, &modules[current], store ? pure_tree_data(store) : (PureData){0}); + if (started != PURE_OK) { fprintf(stderr, "vega: cannot start (%d)\n", (int)started); return false; } + vega_screen(&screen, &modules[current]); + typing = screen.prompt >= 0; + return true; +} + +/* Replace the running app's module with the one read from path, states + * keeping their values by name; a module that does not load or replace + * leaves the app as it was. */ +static bool swap(const char *path, long size) { + PureModule *next = &modules[!current]; + if (!decode(path, size, next)) return false; + PureResult replaced = pure_app_replace(&app, next); + if (replaced != PURE_OK) { + fprintf(stderr, "vega: keeping the running module: cannot replace it (%d)\n", (int)replaced); + return false; + } + bool had_prompt = screen.prompt >= 0; + current = !current; + vega_screen(&screen, next); + typing = screen.prompt >= 0 && (typing || !had_prompt); + return true; +} + +/* --watch: the module file's bytes when last read (their digest), so a + * module written again is seen however soon and whatever its size or file + * times. Read at most five times a second, however often asked; bytes that + * do not load are not tried again. */ +static const char *watched; +static uint64_t watched_digest; +static struct timespec watched_at; + +static bool module_changed(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + if (!watched || (now.tv_sec - watched_at.tv_sec) * 1000000000L + now.tv_nsec - watched_at.tv_nsec < 200000000L) + return false; + watched_at = now; + long size = read_module(watched); + if (size < 0 || module_digest == watched_digest) return false; /* a missing file is looked for again */ + watched_digest = module_digest; + return swap(watched, size); +} + +static int key_input(const char *key) { return vega_key_input(app.module, key); } + +/* 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); + return *input >= 0 && vega_press(&app, key); +} + +static bool commit(const char *text, size_t length) { return vega_commit(&app, &screen, text, length); } + +static const VegaPart *part_named(const char *name) { + for (unsigned i = 0; i < screen.count; ++i) + if (screen.parts[i].kind != VEGA_FIELD && !strcmp(app.module->view_names[screen.parts[i].view], name)) + return &screen.parts[i]; + return NULL; +} + +/* A mouse button on a line of the last frame: a click focuses the row there; + * the wheel moves the focus of the list or table under it. */ +static bool mouse(unsigned button, unsigned line) { + unsigned index; + size_t item; + if (!drawn || !vega_hit(drawn, line, &index, &item)) return false; + const VegaPart *part = &screen.parts[index]; + if (button == 64 || button == 65) { + size_t focus = vega_focus(&app, part), rows = vega_rows(&app, part); + if (focus < rows) { /* else the wheel focuses the row under it */ + if (button == 64 ? !focus : focus + 1 >= rows) return false; + item = button == 64 ? focus - 1 : focus + 1; + } + } else if (button) return false; + return vega_point(&app, part, item); +} + +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 bool same_cell(const VegaCell *a, const VegaCell *b) { + return a->ch == b->ch && a->mark == b->mark && a->tone == b->tone && a->focus == b->focus && a->strong == b->strong; +} + +/* The terminal bytes that turn what it shows into the grid: each run of + * changed cells after a cursor move (a short unchanged gap stays in the run, + * being cheaper than another move), everything after a resize. */ +static void paint(VegaGrid *grid, Out *out) { + bool all = !grid->painted; + int style = -1; + char code[48]; + if (all) out_put(out, "\033[2J", 4); + for (unsigned r = 0; r < grid->height; ++r) { + const VegaCell *line = grid->cells + (size_t)r * grid->width, *old = grid->shown + (size_t)r * grid->width; + for (unsigned c = 0; c < grid->width;) { + if (!all && same_cell(&line[c], &old[c])) { ++c; continue; } + unsigned start = c, end = c + 1; + for (unsigned k = c + 1, gap = 0; k < grid->width && gap <= 6; ++k) { + if (all || !same_cell(&line[k], &old[k])) { end = k + 1; gap = 0; } + else ++gap; + } + while (start && !line[start].ch) --start; /* the second half of a wide character: from its first */ + out_put(out, code, (size_t)snprintf(code, sizeof(code), "\033[%u;%uH", r + 1, start + 1)); + for (unsigned k = start; k < end; ++k) { + if (!line[k].ch) continue; + int next = line[k].tone | line[k].focus << 4 | line[k].strong << 5; + if (next != style) { + out_put(out, code, (size_t)snprintf(code, sizeof(code), "\033[0%s%s%sm", line[k].strong ? ";1" : "", + line[k].focus ? ";7" : "", + sgr_tones[line[k].tone < VEGA_TONES ? line[k].tone : 0])); + style = next; + } + out_codepoint(out, line[k].ch); + if (line[k].mark) out_codepoint(out, line[k].mark); + } + c = end; + } + } + if (style > 0) out_put(out, "\033[0m", 4); + if (grid->cursor_row >= 0) + out_put(out, code, (size_t)snprintf(code, sizeof(code), "\033[%d;%dH\033[?25h", grid->cursor_row + 1, + grid->cursor_column + 1)); + else out_put(out, "\033[?25l", 6); + memcpy(grid->shown, grid->cells, (size_t)grid->width * grid->height * sizeof(*grid->cells)); + grid->painted = true; +} + +static void paint_terminal(VegaGrid *grid) { + Out out = {0}; + paint(grid, &out); + 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[?1006l\033[?1000l\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\033[?1000h\033[?1006h"; /* mouse buttons, reported as SGR */ + 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 && at + 2 < length && in[at + 1] == '[' && in[at + 2] == '<') { + /* A mouse report: ESC [ < button ; column ; line, M on press, m on release. */ + unsigned fields[3] = {0, 0, 0}, field = 0; + size_t end = at + 3; + for (; end < length && field < 3; ++end) { + if (in[end] >= '0' && in[end] <= '9') fields[field] = fields[field] * 10 + (unsigned)(in[end] - '0'); + else if (in[end] == ';') ++field; + else break; + } + if (end < length && in[end] == 'M' && field == 2 && fields[2]) mouse(fields[0], fields[2] - 1); + at = end + 1; + continue; + } + 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}; + drawn = &grid; + VegaTimers timers; + vega_timers_start(&timers, app.module, vega_now()); + 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); + } + /* Tick what is due, draw, then wait for input, a frame or the next tick. */ + bool ticked = false; + long due = vega_timers_run(&timers, &app, vega_now(), &ticked); + vega_draw(&grid, &app, &screen, entry, entry_length, typing); + paint_terminal(&grid); + struct pollfd wait = {STDIN_FILENO, POLLIN, 0}; + int timeout = screen.ticks ? 16 : watched ? 200 : -1; + if (due >= 0 && (timeout < 0 || due < timeout)) timeout = (int)due; + int ready = poll(&wait, 1, timeout); + app.now = vega_now(); + if (ready == 0) { + if (screen.ticks) pure_app_frame(&app); + if (module_changed()) vega_timers_start(&timers, app.module, app.now); + 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(); + drawn = NULL; + vega_grid_free(&grid); + return 0; +} + +/* ---- scripts -------------------------------------------------------- */ + +typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI, SURFACE_WEB } Surface; + +/* painting: show writes the terminal bytes of the frame, not the grid. */ +static int script(Surface surface, unsigned width, unsigned height, bool painting) { + static char line[1 << 16]; + VegaGrid grid = {0}; + if (surface == SURFACE_TUI && !vega_grid(&grid, width, height)) return 2; + bool shown = false; + drawn = surface == SURFACE_TUI ? &grid : NULL; + 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; + const char *verb = "do that"; + 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, "point")) { + char name[PURE_MODULE_NAME]; + long row = -1; + const VegaPart *part = sscanf(line, "%*s %31s %ld", name, &row) == 2 ? part_named(name) : NULL; + verb = "point"; + if (!part) fprintf(stderr, "vega: no list or table %s\n", name); + ok = part && row >= 0 && vega_point(&app, part, (size_t)row); + } else if (!strcmp(command, "click")) { + unsigned at_line = 0, at_column = 0; + verb = "point"; + if (surface != SURFACE_TUI) { fputs("vega: click needs the tui\n", stderr); continue; } + acted = sscanf(line, "%*s %u %u", &at_line, &at_column) == 2 && at_line && shown; + ok = acted && mouse(0, at_line - 1); + } else if (!strcmp(command, "time")) { + /* The time the next events see: scripts replay any. */ + char *end; + double now = strtod(argument, &end); + acted = false; + if (!*argument || *end || !(now >= 0 && now < 1e15)) fprintf(stderr, "vega: time takes milliseconds\n"); + else app.now = now; + } else if (!strcmp(command, "tick")) { + input = pure_module_input(app.module, argument); + verb = "tick"; + if (input < 0 || app.module->input[input].kind != PURE_KIND_TIMER) { + fprintf(stderr, "vega: no timer %s\n", argument); + input = -1; + ok = false; + } else ok = pure_app_tick(&app, (uint32_t)input) == PURE_OK; + } else if (!strcmp(command, "swap")) { + acted = false; + if (!swap(argument, read_module(argument))) fprintf(stderr, "vega: kept the running module\n"); + } 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 if ((shown = true) && painting) { + Out out = {0}; + vega_draw(&grid, &app, &screen, entry, entry_length, typing); + paint(&grid, &out); + printf("paint %zu\n", out.used); + fwrite(out.bytes, 1, out.used, stdout); + putchar('\n'); + free(out.bytes); + } 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", app.module->input_names[input]); + if (surface == SURFACE_SAY && acted) { + if (!ok) printf("Could not %s.\n", input >= 0 ? app.module->input_names[input] : verb); + else vega_say(stdout, &app, &screen, false); + } + fflush(stdout); + } + drawn = NULL; + vega_grid_free(&grid); + return 0; +} + +int main(int argc, char **argv) { + unsigned width = 0, height = 0, port = 8080; + bool dumped = false, painting = false, watch = false, usage = argc < 3; + const char *store = NULL, *listen_on = "127.0.0.1"; + for (int i = 3; !usage && i < argc; i += 2) { + if (!strcmp(argv[i], "--watch") && (!strcmp(argv[1], "tui") || !strcmp(argv[1], "web"))) { + watch = true; + --i; /* a flag without a value */ + continue; + } + if (i + 1 >= argc) usage = true; + else if (!strcmp(argv[i], "--store")) store = argv[i + 1]; + else if (!strcmp(argv[i], "--listen") && !strcmp(argv[1], "web")) listen_on = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && !strcmp(argv[1], "web") && sscanf(argv[i + 1], "%u", &port) == 1 && + port < 65536) {} + else if ((!strcmp(argv[i], "--dump") || !strcmp(argv[i], "--paint")) && + sscanf(argv[i + 1], "%ux%u", &width, &height) == 2 && width && height && width <= 1000 && height <= 1000) + dumped = true, painting = !strcmp(argv[i], "--paint"); + else usage = true; + } + if (usage || (dumped && strcmp(argv[1], "tui")) || (!strcmp(argv[1], "check") && argc != 3)) { + fputs("vega tui|cli|say MODULE.pmod [--store PATH]\n" + "vega tui MODULE.pmod [--store PATH] [--watch] | --dump|--paint WIDTHxHEIGHT < script\n" + "vega web MODULE.pmod [--store PATH] [--listen ADDRESS] [--port N] [--watch]\n" + "vega check MODULE.pmod\n", stderr); + return 2; + } + if (!strcmp(argv[1], "check")) { + if (!load_module(argv[2], &modules[0])) return 2; + return vega_check(&modules[0], print_finding, argv[2]) ? 1 : 0; + } + Surface surface = !strcmp(argv[1], "cli") ? SURFACE_CLI : !strcmp(argv[1], "say") ? SURFACE_SAY : + !strcmp(argv[1], "tui") ? SURFACE_TUI : !strcmp(argv[1], "web") ? SURFACE_WEB : (Surface)-1; + if ((int)surface < 0) { fprintf(stderr, "vega: unknown surface %s\n", argv[1]); return 2; } + if (!load(argv[2], store)) return 2; + if (watch && !dumped) { + watched = argv[2]; + watched_digest = module_digest; /* of what load read */ + } + int status = surface == SURFACE_WEB ? vega_web(&app, &screen, listen_on, port, watched ? module_changed : NULL) : + surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height, painting); + /* Frame changes are stored with events; keep the last ones too. */ + if (pure_app_save(&app) != PURE_OK) { fputs("vega: cannot save\n", stderr); status = status ? status : 1; } + pure_app_stop(&app); + return status; +} diff --git a/surfaces/screen.c b/surfaces/screen.c new file mode 100644 index 0000000..fdecaf4 --- /dev/null +++ b/surfaces/screen.c @@ -0,0 +1,229 @@ +#define _POSIX_C_SOURCE 200809L +#include "vega.h" +#include "module-text.h" +#include <math.h> +#include <string.h> +#include <time.h> + +double vega_now(void) { + struct timespec t; + clock_gettime(CLOCK_REALTIME, &t); + return floor((double)t.tv_sec * 1000 + (double)t.tv_nsec / 1e6); +} + +void vega_timers_start(VegaTimers *timers, const PureModule *m, double now) { + for (uint32_t i = 0; i < m->inputs; ++i) + timers->next[i] = m->input[i].kind == PURE_KIND_TIMER ? now + m->input[i].period : 0; +} + +long vega_timers_run(VegaTimers *timers, PureApp *app, double now, bool *ticked) { + const PureModule *m = app->module; + double soonest = -1; + for (uint32_t i = 0; i < m->inputs; ++i) { + if (m->input[i].kind != PURE_KIND_TIMER) continue; + if (timers->next[i] <= now) { + app->now = now; + pure_app_tick(app, i); /* a failed tick changes nothing, as any failed event */ + *ticked = true; + timers->next[i] = now + m->input[i].period; + } + if (soonest < 0 || timers->next[i] - now < soonest) soonest = timers->next[i] - now; + } + return soonest < 0 ? -1 : (long)ceil(soonest); +} + +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; + for (uint32_t h = 0; h < m->handlers; ++h) screen->ticks = screen->ticks || m->handler[h].event == 0; + /* 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, .pointer = -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->pointer = part->focus >= 0 ? pure_module_position(m, (uint32_t)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; + } +} + +static bool is_view(const PureModule *m, int view, uint8_t type) { + return view >= 0 && m->view_type[view] == type; +} + +unsigned vega_check(const PureModule *m, void (*report)(void *, const char *name, const char *message), void *context) { + unsigned found = 0; + char name[2 * PURE_MODULE_NAME + 8], message[256]; +#define FIND(...) do { snprintf(message, sizeof(message), __VA_ARGS__); report(context, name, message); ++found; } while (0) + int title = pure_module_view(m, "title"); + snprintf(name, sizeof(name), "title"); + if (title >= 0 && m->view_type[title] != PURE_VIEW_TEXT) FIND("a title is text; this one shows as a field"); + for (uint32_t i = 0; i < m->views; ++i) { + if (m->view_table[i]) continue; + const char *view = m->view_names[i]; + size_t length = strlen(view); + snprintf(name, sizeof(name), "%s", view); + if (length > 6 && !strcmp(view + length - 6, "_focus")) { + char table[PURE_MODULE_NAME]; + snprintf(table, sizeof(table), "%.*s", (int)(length - 6), view); + int owner = pure_module_view(m, table); + if (!is_view(m, owner, PURE_VIEW_TABLE)) + FIND("names no list or table %s; it shows as a field", table); + else if (m->view_type[i] != PURE_VIEW_NUM) FIND("the focused row is a number; %s shows no focus", table); + } + if (m->view_type[i] != PURE_VIEW_TABLE) continue; + int label = pure_module_column(m, i, "label"), tone = pure_module_column(m, i, "tone"); + bool list = is_view(m, label, PURE_VIEW_TEXT); + unsigned shown = 0; + for (uint32_t k = 0; k < m->views; ++k) { + if (m->view_table[k] != i + 1) continue; + const char *column = m->view_names[k]; + bool text = m->view_type[k] == PURE_VIEW_TEXT; + snprintf(name, sizeof(name), "%s.%s", view, column); + if ((int)k == label && !text) FIND("a list's label is text; %s shows as a table", view); + else if ((int)k == tone && text) FIND("a tone is a number (vega.normal ... vega.danger); %s", list ? + "the list ignores it" : "it shows as a column"); + else if (list && (!strcmp(column, "badge") || !strcmp(column, "detail")) && !text) + FIND("a list's %s is text; this one is not shown", column); + else if (list && (int)k != label && (int)k != tone && strcmp(column, "badge") && strcmp(column, "detail")) + FIND("a list shows label, badge, detail and tone; this column is not shown"); + shown += (int)k != tone || text; + } + snprintf(name, sizeof(name), "%s", view); + if (!list && !shown) FIND("a table with no column to show"); + } + int prompt = -1; + for (uint32_t i = 0; i < m->inputs; ++i) { + if (m->input[i].kind != PURE_KIND_TEXT) continue; + snprintf(name, sizeof(name), "%s", m->input_names[i]); + if (prompt >= 0) FIND("only the first text input (%s) has a place to type into", m->input_names[prompt]); + else prompt = (int)i; + } +#undef FIND + return found; +} + +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(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), row; + double focus; + if (part->focus < 0 || !rows || !vega_number(app, (uint32_t)part->focus, 0, &focus) || !(focus >= 0)) return 0; + bool filtered = app->module->view_code[part->view].length > 0; + size_t item = focus < (double)SIZE_MAX ? (size_t)focus : SIZE_MAX; + if (pure_app_item_row(app, part->view, item, &row)) return row; + return filtered ? VEGA_NO_ROW : rows - 1; +} + +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows) { + if (focus < rows && focus < first) first = focus; + if (focus < rows && visible && focus >= first + visible) first = focus - visible + 1; + if (first + visible > rows) first = rows > visible ? rows - visible : 0; + return first; +} + +int vega_key_input(const PureModule *m, const char *key) { + for (uint32_t i = 0; i < m->inputs; ++i) + if (m->input[i].kind == PURE_KIND_KEY && !strcmp(m->input_keys[i], key)) return (int)i; + return -1; +} + +bool vega_press(PureApp *app, const char *key) { + int input = vega_key_input(app->module, key); + if (input < 0) return false; + bool ok = pure_app_input(app, (uint32_t)input, PURE_EDGE_DOWN) == PURE_OK; + pure_app_input(app, (uint32_t)input, PURE_EDGE_UP); + return ok; +} + +bool vega_commit(PureApp *app, const VegaScreen *screen, const char *text, size_t length) { + return screen->prompt >= 0 && + pure_app_text(app, (uint32_t)screen->prompt, (PureSpan){(const unsigned char *)text, length}) == PURE_OK; +} + +bool vega_point(PureApp *app, const VegaPart *part, size_t row) { + size_t item; /* a filtered list's row shows some item of the list */ + return part->pointer >= 0 && pure_app_row_item(app, part->view, row, &item) && + pure_app_point(app, (uint32_t)part->pointer, item) == PURE_OK; +} + +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/surfaces/vega.h b/surfaces/vega.h new file mode 100644 index 0000000..f01667b --- /dev/null +++ b/surfaces/vega.h @@ -0,0 +1,124 @@ +#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 pointer; /* the index state that view shows, or -1: a surface may move it */ + 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 */ + bool ticks; /* it handles frames: a surface runs them, about 60 a second */ + VegaPart parts[VEGA_PARTS]; + unsigned count; +} VegaScreen; + +void vega_screen(VegaScreen *, const PureModule *); + +/* The wall-clock time events see on a live surface: milliseconds since 1970 + * (UTC). Scripts set their own (time MS). */ +double vega_now(void); +/* When each timer input ticks next. run ticks every one due at now (a tick + * sees now) and gives the milliseconds until the next, or -1 without timers; + * ticked says whether any did. Missed ticks are not made up. */ +typedef struct { double next[PURE_MODULE_INPUTS]; } VegaTimers; +void vega_timers_start(VegaTimers *, const PureModule *, double now); +long vega_timers_run(VegaTimers *, PureApp *, double now, bool *ticked); +/* What a module seems to mean but does not meet: a list label that is not + * text, a _focus view without its table, a column a list never shows, a + * second text input. Each finding names the view or input it is about; + * returns how many there were. */ +unsigned vega_check(const PureModule *, void (*report)(void *, const char *name, const char *message), void *context); + +/* 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(PureApp *, const VegaPart *); +/* The row of the focused item: the focus view names an item (an index's + * position), shown on the row that holds it; VEGA_NO_ROW when a filtered + * list leaves that item out. Clamped to the rows without a filter; 0 + * without a focus view. */ +#define VEGA_NO_ROW SIZE_MAX +size_t vega_focus(PureApp *, const VegaPart *); +/* Focus a row, as a user pointing at it (a click); false when the part's + * focus is not a plain index position or the row is not there. */ +bool vega_point(PureApp *, const VegaPart *, size_t row); +/* The first row to show of `rows` when `visible` fit: the least scrolling + * from first that keeps the focused row in view (any, without one). */ +size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows); +/* The key input answering a key name (Pure's names, as KeyboardEvent.code + * spells them), or -1; press sends its press and release. */ +int vega_key_input(const PureModule *, const char *key); +bool vega_press(PureApp *, const char *key); +/* Commit text to the screen's text input. */ +bool vega_commit(PureApp *, const VegaScreen *, const char *text, size_t length); +VegaTone vega_tone(PureApp *, const VegaPart *, size_t row); +/* Values evaluated so far, for checking that surfaces stay lazy. */ +unsigned long vega_evaluations(void); + +/* 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); +/* GUI: the screen as a web page on listen:port (0: any free port, printed as + * {"port":N}); runs until SIGINT or SIGTERM. reload, when given, is asked + * a few times a second whether the module was replaced (the screen with it). */ +int vega_web(PureApp *, const VegaScreen *, const char *listen, unsigned port, bool (*reload)(void)); + +/* TUI: a grid of cells, redrawn from the app on every change. first keeps each + * list's scroll between frames; the focused row stays visible. shown is what + * the terminal shows, so a paint sends only the cells that changed (all of + * them until the first paint after a resize). */ +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, *shown; + struct VegaRow { int part; size_t item; } *rows; /* the item each line shows, or part -1 */ + bool painted; /* shown holds what the terminal shows */ + 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); +/* The part and item a line of the last drawn grid shows. */ +bool vega_hit(const VegaGrid *, unsigned line, unsigned *part, size_t *item); +#endif diff --git a/surfaces/web.c b/surfaces/web.c new file mode 100644 index 0000000..535d92f --- /dev/null +++ b/surfaces/web.c @@ -0,0 +1,453 @@ +#define _GNU_SOURCE +#include "vega.h" +#include <arpa/inet.h> +#include <errno.h> +#include <fcntl.h> +#include <netinet/in.h> +#include <poll.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <unistd.h> + +/* vega web: the screen as a web page, a GUI surface. The page asks for the + * screen as JSON with the rows it can show per list or table, so only the + * cells on the page are evaluated; it sends keys (KeyboardEvent.code names are + * Pure's), committed text and clicks on rows, and an event stream tells every + * open page when the app changed. + * + * GET / the page + * GET /screen?rows=R&first=F,G title, fields, each list or table with R rows + * from its last first row (F, G, ... by part), keys + * POST /key KEYNAME press and release the input bound to the key + * POST /text UTF-8 commit text to the screen's text input + * POST /point PART ROW focus a row of a list or table (0 first) + * GET /events server-sent events: one per change */ + +enum { CLIENTS = 64, REQUEST = 1 << 16, ROWS_MAX = 500 }; + +typedef struct { char *bytes; size_t used, capacity; } Buffer; + +typedef struct { + int fd; + bool events; /* an open event stream */ + bool closing; + char in[REQUEST]; + size_t in_used; + Buffer out; + size_t out_at; +} Client; + +static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", "good", "warning", "danger"}; +static volatile sig_atomic_t stopping; + +static void on_stop(int signal_number) { + (void)signal_number; + stopping = 1; +} + +static void put(Buffer *b, const void *bytes, size_t size) { + if (b->used + size > b->capacity) { + size_t capacity = b->capacity ? b->capacity : 4096; + while (capacity < b->used + size) capacity *= 2; + char *grown = realloc(b->bytes, capacity); + if (!grown) return; + b->bytes = grown; + b->capacity = capacity; + } + memcpy(b->bytes + b->used, bytes, size); + b->used += size; +} + +static void puts_(Buffer *b, const char *text) { put(b, text, strlen(text)); } + +static void putf(Buffer *b, const char *format, ...) __attribute__((format(printf, 2, 3))); +#include <stdarg.h> +static void putf(Buffer *b, const char *format, ...) { + char line[256]; + va_list args; + va_start(args, format); + int n = vsnprintf(line, sizeof(line), format, args); + va_end(args); + if (n > 0) put(b, line, (size_t)n < sizeof(line) ? (size_t)n : sizeof(line) - 1); +} + +/* A JSON string: UTF-8 as it is, quotes, backslashes and control characters escaped. */ +static void json_string(Buffer *b, const char *text, size_t length) { + put(b, "\"", 1); + for (size_t i = 0; i < length; ++i) { + unsigned char c = (unsigned char)text[i]; + if (c == '"' || c == '\\') { put(b, "\\", 1); put(b, &text[i], 1); } + else if (c < 0x20 || c == 0x7F) putf(b, "\\u%04x", c); + else put(b, &text[i], 1); + } + put(b, "\"", 1); +} + +static void json_name(Buffer *b, const char *name) { json_string(b, name, strlen(name)); } + +/* A view's text at a row, or null. */ +static void json_view(Buffer *b, PureApp *app, uint32_t view, size_t row) { + char text[VEGA_TEXT]; + size_t length; + if (vega_text(app, view, row, text, &length)) json_string(b, text, length); + else puts_(b, "null"); +} + +static void screen_json(Buffer *b, PureApp *app, const VegaScreen *screen, size_t visible, size_t *firsts) { + const PureModule *m = app->module; + puts_(b, "{\"title\":"); + if (screen->title >= 0) json_view(b, app, (uint32_t)screen->title, 0); + else puts_(b, "null"); + puts_(b, ",\"fields\":["); + bool comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind != VEGA_FIELD) continue; + puts_(b, comma ? ",{\"name\":" : "{\"name\":"); + json_name(b, m->view_names[part->view]); + puts_(b, ",\"text\":"); + json_view(b, app, part->view, 0); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"parts\":["); + comma = false; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + size_t rows = vega_rows(app, part), focus = vega_focus(app, part); + size_t first = vega_scroll(firsts[i], focus, visible, rows); + firsts[i] = first; + puts_(b, comma ? ",{\"part\":" : "{\"part\":"); + putf(b, "%u,\"name\":", i); + json_name(b, m->view_names[part->view]); + putf(b, ",\"kind\":\"%s\",\"rows\":%zu,\"focus\":", part->kind == VEGA_LIST ? "list" : "table", rows); + if (focus < rows) putf(b, "%zu", focus); + else puts_(b, "null"); + putf(b, ",\"first\":%zu,\"pointable\":%s,\"columns\":[", first, part->pointer >= 0 ? "true" : "false"); + if (part->kind == VEGA_TABLE) + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_name(b, m->view_names[part->columns[c]]); + } + puts_(b, "],\"items\":["); + for (size_t row = first; row < first + visible && row < rows; ++row) { + putf(b, "%s{\"row\":%zu,\"tone\":\"%s\"", row > first ? "," : "", row, + tone_names[vega_tone(app, part, row)]); + if (part->kind == VEGA_LIST) { + const int columns[] = {part->label, part->badge, part->detail}; + const char *const names[] = {"label", "badge", "detail"}; + for (unsigned k = 0; k < 3; ++k) { + if (columns[k] < 0) continue; + putf(b, ",\"%s\":", names[k]); + json_view(b, app, (uint32_t)columns[k], row); + } + } else { + puts_(b, ",\"cells\":["); + for (unsigned c = 0; c < part->column_count; ++c) { + if (c) puts_(b, ","); + json_view(b, app, part->columns[c], row); + } + puts_(b, "]"); + } + puts_(b, "}"); + } + puts_(b, "]}"); + comma = true; + } + puts_(b, "],\"keys\":["); + comma = false; + for (uint32_t i = 0; i < m->inputs; ++i) { + if (m->input[i].kind != PURE_KIND_KEY) continue; + bool bound = false; + for (uint32_t h = 0; h < m->handlers && !bound; ++h) bound = m->handler[h].event == 1 + 2 * i; + if (!bound) continue; + puts_(b, comma ? ",{\"input\":" : "{\"input\":"); + json_name(b, m->input_names[i]); + puts_(b, ",\"key\":"); + json_name(b, m->input_keys[i]); + puts_(b, "}"); + comma = true; + } + puts_(b, "],\"prompt\":"); + if (screen->prompt >= 0) json_name(b, m->input_names[screen->prompt]); + else puts_(b, "null"); + putf(b, ",\"events\":%llu}", (unsigned long long)app->events); +} + +/* The page: one file, no dependencies. It keeps each part's first row, asks + * for as many rows as fit, and redraws when the event stream says so. */ +static const char *const page_parts[] = { +"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Vega</title><style>" +":root{color-scheme:light dark;--fg:#1d1d1f;--bg:#fbfbfd;--line:#d2d2d7;--focus:#0a58ca;--muted:#86868b;" +"--accent:#0071e3;--good:#1e8e3e;--warning:#b06000;--danger:#d93025}" +"@media(prefers-color-scheme:dark){:root{--fg:#f5f5f7;--bg:#161618;--line:#3a3a3c;--focus:#4c8dff;--muted:#8e8e93;" +"--accent:#64a8ff;--good:#5ec26a;--warning:#f0a33a;--danger:#ff6b5e}}" +"body{margin:0;font:15px/1.5 system-ui,-apple-system,'Noto Sans KR',sans-serif;color:var(--fg);background:var(--bg)}" +"main{max-width:760px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 12px}" +"dl{display:grid;grid-template-columns:max-content 1fr;gap:2px 16px;margin:0 0 16px}dt{color:var(--muted)}dd{margin:0}" +"section{margin:0 0 20px}h2{font-size:14px;font-weight:600;margin:0 0 6px}h2 span{color:var(--muted);font-weight:400}" +"ul{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden}" +"li,tbody tr{cursor:pointer}li{display:flex;gap:10px;padding:7px 12px;border-top:1px solid var(--line)}" +"li:first-child{border-top:0}.label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:pre}" +".detail{color:var(--muted);white-space:pre}.badge{font-size:12px;padding:1px 8px;border:1px solid currentColor;" +"border-radius:10px;align-self:center}[aria-selected=true]{background:var(--focus);color:#fff}" +"table{width:100%;border-collapse:collapse;border:1px solid var(--line)}th{text-align:left;color:var(--muted);" +"font-weight:500}th,td{padding:6px 12px;border-top:1px solid var(--line);white-space:pre}" +".muted{color:var(--muted)}.accent{color:var(--accent)}.good{color:var(--good)}.warning{color:var(--warning)}" +".danger{color:var(--danger)}[aria-selected=true].muted,[aria-selected=true] *{color:#fff}" +"form{display:flex;gap:8px;margin-top:16px}input{flex:1;font:inherit;padding:8px 10px;border:1px solid var(--line);" +"border-radius:8px;background:transparent;color:inherit}button{font:inherit;padding:8px 16px;border:1px solid var(--line);" +"border-radius:8px;background:transparent;color:inherit;cursor:pointer}button:hover{border-color:var(--focus)}" +"footer{margin-top:14px;color:var(--muted);font-size:13px}" +"kbd{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-right:4px}" +"</style></head><body><main>", +"<h1 id=title></h1><dl id=fields></dl><div id=parts></div>" +"<form id=prompt hidden><input id=entry autocomplete=off><button>Enter</button></form><footer id=keys></footer></main>" +"<script>", +"'use strict';let screen=null,firsts=[],busy=false,again=false;const rows=Math.max(5,Math.floor((innerHeight-220)/34));" +"const $=id=>document.getElementById(id);" +"const el=(tag,props,...kids)=>{const e=document.createElement(tag);Object.assign(e,props||{});" +"for(const k of kids)if(k!=null)e.append(k);return e};" +"async function post(path,body){await fetch(path,{method:'POST',body});refresh()}" +"async function refresh(){if(busy){again=true;return}busy=true;try{" +"const r=await fetch('/screen?rows='+rows+'&first='+firsts.join(','));screen=await r.json();render()}" +"finally{busy=false;if(again){again=false;refresh()}}}" +"function render(){const s=screen;$('title').textContent=s.title?\?'';document.title=s.title?\?'Vega';" +"$('fields').replaceChildren(...s.fields.flatMap(f=>[el('dt',{textContent:f.name}),el('dd',{textContent:f.text?\?'-'})]));" +"firsts=s.parts.map(p=>p.first);" +"$('parts').replaceChildren(...s.parts.map(p=>{const head=el('h2',{},p.name+' ',el('span',{textContent:'('+p.rows+')'}));" +"const pick=row=>{if(p.pointable)post('/point',p.part+' '+row)};" +"if(p.kind==='list'){const list=el('ul',{role:'listbox'});list.setAttribute('aria-label',p.name);" +"for(const it of p.items){const item=el('li',{className:it.tone,onclick:()=>pick(it.row)}," +"el('span',{className:'label',textContent:it.label?\?''}),it.detail?el('span',{className:'detail',textContent:it.detail}):null," +"it.badge?el('span',{className:'badge',textContent:it.badge}):null);item.setAttribute('role','option');" +"item.setAttribute('aria-selected',it.row===p.focus);list.append(item)}" +"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" +"return el('section',{},head,list)}" +"const table=el('table',{},el('thead',{},el('tr',{},...p.columns.map(c=>el('th',{textContent:c})))));" +"const body=el('tbody');for(const it of p.items){const tr=el('tr',{className:it.tone,onclick:()=>pick(it.row)}," +"...it.cells.map(c=>el('td',{textContent:c?\?'-'})));tr.setAttribute('aria-selected',it.row===p.focus);body.append(tr)}" +"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" +"return el('section',{},head,table)}));" +"$('prompt').hidden=!s.prompt;if(s.prompt)$('entry').placeholder=s.prompt;" +"const symbols={ArrowUp:'\u2191',ArrowDown:'\u2193',ArrowLeft:'\u2190',ArrowRight:'\u2192',Backspace:'Bksp'," +"Delete:'Del',Escape:'Esc',PageUp:'PgUp',PageDown:'PgDn'};" +"$('keys').replaceChildren(...s.keys.map(k=>el('span',{},el('kbd',{textContent:symbols[k.key]?\?k.key.replace(/^(Key|Digit)/,'')})," +"k.input+' ')))}" +"$('prompt').onsubmit=e=>{e.preventDefault();const v=$('entry').value;if(v){$('entry').value='';post('/text',v)}};" +"addEventListener('keydown',e=>{if(!screen||e.ctrlKey||e.metaKey||e.altKey)return;" +"const typing=document.activeElement===$('entry');" +"if(typing&&!['ArrowUp','ArrowDown','PageUp','PageDown','Escape'].includes(e.code))return;" +"if(screen.keys.some(k=>k.key===e.code)){e.preventDefault();post('/key',e.code)}});" +"new EventSource('/events').onmessage=refresh;refresh();" +"</script></body></html>"}; + +/* The page, joined once from its pieces. */ +static const Buffer *page(void) { + static Buffer joined; + if (!joined.used) + for (size_t i = 0; i < sizeof(page_parts) / sizeof(*page_parts); ++i) puts_(&joined, page_parts[i]); + return &joined; +} + +static void respond(Client *c, int status, const char *reason, const char *type, const char *body, size_t length) { + putf(&c->out, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\nCache-Control: no-store\r\n%s\r\n", + status, reason, type, length, c->closing ? "Connection: close\r\n" : ""); + put(&c->out, body, length); +} + +static bool query_number(const char *query, const char *name, size_t *out) { + size_t length = strlen(name); + for (const char *at = query; at && *at; at = strchr(at, '&') ? strchr(at, '&') + 1 : NULL) + if (!strncmp(at, name, length) && at[length] == '=') { + *out = strtoul(at + length + 1, NULL, 10); + return true; + } + return false; +} + +/* first=F,G,...: the parts' first rows, in the order of the screen's parts. */ +static void query_firsts(const char *query, const VegaScreen *screen, size_t *firsts) { + const char *at = query ? strstr(query, "first=") : NULL; + if (!at || (at != query && at[-1] != '&')) return; + at += 6; + for (unsigned i = 0; i < screen->count && *at && *at != '&'; ++i) { + if (screen->parts[i].kind == VEGA_FIELD) continue; + firsts[i] = strtoul(at, (char **)&at, 10); + if (*at == ',') ++at; + } +} + +/* One request. Returns whether the app changed. */ +static bool handle(Client *c, PureApp *app, const VegaScreen *screen, const char *method, char *target, + const char *body, size_t body_size) { + char *query = strchr(target, '?'); + if (query) *query++ = 0; + bool get = !strcmp(method, "GET"), post = !strcmp(method, "POST"); + uint64_t before = app->events; + if (get && !strcmp(target, "/")) { + respond(c, 200, "OK", "text/html; charset=utf-8", page()->bytes, page()->used); + return false; + } + if (get && !strcmp(target, "/screen")) { + size_t visible = 50, firsts[VEGA_PARTS] = {0}; + query_number(query, "rows", &visible); + if (visible > ROWS_MAX) visible = ROWS_MAX; + query_firsts(query, screen, firsts); + Buffer json = {0}; + screen_json(&json, app, screen, visible, firsts); + respond(c, 200, "OK", "application/json", json.bytes ? json.bytes : "", json.used); + free(json.bytes); + return false; + } + if (get && !strcmp(target, "/events")) { + puts_(&c->out, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n\r\n"); + putf(&c->out, "data: %llu\n\n", (unsigned long long)app->events); + c->events = true; + return false; + } + if (post && (!strcmp(target, "/key") || !strcmp(target, "/text") || !strcmp(target, "/point"))) { + char text[VEGA_TEXT]; + size_t length = body_size < sizeof(text) - 1 ? body_size : sizeof(text) - 1; + memcpy(text, body, length); + text[length] = 0; + bool ok = false; + if (target[1] == 'k') ok = vega_press(app, text); + else if (target[1] == 't') ok = vega_commit(app, screen, text, length); + else { + unsigned part = 0; + long row = -1; + ok = sscanf(text, "%u %ld", &part, &row) == 2 && part < screen->count && row >= 0 && + screen->parts[part].kind != VEGA_FIELD && vega_point(app, &screen->parts[part], (size_t)row); + } + const char *answer = ok ? "{\"ok\":true}" : "{\"ok\":false}"; + respond(c, 200, "OK", "application/json", answer, strlen(answer)); + return app->events != before; + } + respond(c, 404, "Not Found", "text/plain", "not found\n", 10); + return false; +} + +/* The requests complete in c->in, answered in order. */ +static bool serve(Client *c, PureApp *app, const VegaScreen *screen) { + bool changed = false; + while (!c->events && !c->closing) { + char *end = memmem(c->in, c->in_used, "\r\n\r\n", 4); + if (!end) { + if (c->in_used == sizeof(c->in)) c->closing = true; + return changed; + } + size_t head = (size_t)(end - c->in) + 4, body_size = 0; + *end = 0; + char *length = strcasestr(c->in, "\r\ncontent-length:"); + if (length) body_size = strtoul(length + 17, NULL, 10); + if (body_size > sizeof(c->in) - head) { c->closing = true; return changed; } + if (c->in_used < head + body_size) { *end = '\r'; return changed; } + if (strcasestr(c->in, "\r\nconnection: close")) c->closing = true; + char method[8] = "", target[1024] = ""; + if (sscanf(c->in, "%7s %1023s", method, target) == 2) + changed = handle(c, app, screen, method, target, c->in + head, body_size) || changed; + else c->closing = true; + memmove(c->in, c->in + head + body_size, c->in_used - head - body_size); + c->in_used -= head + body_size; + } + return changed; +} + +static void drop(Client *c) { + close(c->fd); + free(c->out.bytes); + c->fd = -1; +} + +int vega_web(PureApp *app, const VegaScreen *screen, const char *listen_on, unsigned port, bool (*reload)(void)) { + int listener = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0), one = 1; + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port)}; + socklen_t size = sizeof(address); + if (listener < 0 || inet_pton(AF_INET, listen_on, &address.sin_addr) != 1 || + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) || + bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, 64) || + getsockname(listener, (struct sockaddr *)&address, &size)) { + fprintf(stderr, "vega: cannot listen on %s:%u\n", listen_on, port); + return 2; + } + printf("{\"port\":%u}\n", ntohs(address.sin_port)); + fflush(stdout); + signal(SIGINT, on_stop); + signal(SIGTERM, on_stop); + signal(SIGPIPE, SIG_IGN); + static Client clients[CLIENTS]; + for (unsigned i = 0; i < CLIENTS; ++i) clients[i].fd = -1; + VegaTimers timers; + vega_timers_start(&timers, app->module, vega_now()); + while (!stopping) { + /* Tick what is due; every page hears of it this round, without waiting. */ + bool ticked = false; + long due = vega_timers_run(&timers, app, vega_now(), &ticked); + struct pollfd fds[CLIENTS + 1]; + unsigned slot[CLIENTS + 1], count = 1; + fds[0] = (struct pollfd){listener, POLLIN, 0}; + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + fds[count] = (struct pollfd){c->fd, (short)(POLLIN | (c->out_at < c->out.used ? POLLOUT : 0)), 0}; + slot[count++] = i; + } + int timeout = ticked ? 0 : screen->ticks ? 33 : reload ? 200 : 1000; + if (due >= 0 && due < timeout) timeout = (int)due; + int ready = poll(fds, count, timeout); + if (ready < 0 && errno != EINTR) break; + bool changed = ticked; + app->now = vega_now(); /* what the events of this round see */ + if (ready == 0 && screen->ticks) changed = pure_app_frame(app) == PURE_OK; + if (reload && reload()) { /* a new module: every page draws it */ + changed = true; + vega_timers_start(&timers, app->module, app->now); + } + if (fds[0].revents & POLLIN) + for (int fd; (fd = accept4(listener, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC)) >= 0;) { + unsigned free_slot = CLIENTS; + for (unsigned i = 0; i < CLIENTS && free_slot == CLIENTS; ++i) if (clients[i].fd < 0) free_slot = i; + if (free_slot == CLIENTS) { close(fd); continue; } + clients[free_slot] = (Client){.fd = fd}; + } + for (unsigned k = 1; k < count; ++k) { + Client *c = &clients[slot[k]]; + if (fds[k].revents & (POLLIN | POLLHUP | POLLERR)) { + ssize_t n = read(c->fd, c->in + c->in_used, sizeof(c->in) - c->in_used); + if (n > 0) { + c->in_used += (size_t)n; + changed = serve(c, app, screen) || changed; + } else if (n == 0 || (errno != EAGAIN && errno != EINTR)) { drop(c); continue; } + } + } + /* Every open page hears of a change once. */ + if (changed) + for (unsigned i = 0; i < CLIENTS; ++i) + if (clients[i].fd >= 0 && clients[i].events) + putf(&clients[i].out, "data: %llu\n\n", (unsigned long long)app->events); + for (unsigned i = 0; i < CLIENTS; ++i) { + Client *c = &clients[i]; + if (c->fd < 0) continue; + while (c->out_at < c->out.used) { + ssize_t n = write(c->fd, c->out.bytes + c->out_at, c->out.used - c->out_at); + if (n <= 0) { + if (n < 0 && (errno == EAGAIN || errno == EINTR)) break; + c->closing = true; + c->out_at = c->out.used; + break; + } + c->out_at += (size_t)n; + } + if (c->out_at == c->out.used) c->out_at = c->out.used = 0; + if (c->closing && !c->out.used) drop(c); + } + } + for (unsigned i = 0; i < CLIENTS; ++i) if (clients[i].fd >= 0) drop(&clients[i]); + close(listener); + return 0; +} diff --git a/surfaces/width.c b/surfaces/width.c new file mode 100644 index 0000000..1e893f6 --- /dev/null +++ b/surfaces/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; +} |
