diff options
| -rw-r--r-- | docs/design/001-first-component.md | 15 | ||||
| -rw-r--r-- | src/draw.c | 8 | ||||
| -rw-r--r-- | src/main.c | 96 | ||||
| -rw-r--r-- | src/vega.h | 7 | ||||
| -rw-r--r-- | tests/check.py | 104 |
5 files changed, 191 insertions, 39 deletions
diff --git a/docs/design/001-first-component.md b/docs/design/001-first-component.md index 5033389..0161af9 100644 --- a/docs/design/001-first-component.md +++ b/docs/design/001-first-component.md @@ -35,7 +35,7 @@ view tasks_list_focus = at.position | 표면 | 명령 | 하는 일 | |---|---|---| -| TUI | `vega tui app.pmod` | 대체 화면, 원시 입력. 제목·필드·목록·표·키 목록·입력 줄. 포커스 행을 최소 스크롤로 화면에 두고 **보이는 행의 칸만** 계산한다. 크기 변경(SIGWINCH), frame 핸들러가 있으면 60 Hz | +| TUI | `vega tui app.pmod` | 대체 화면, 원시 입력. 제목·필드·목록·표·키 목록·입력 줄. 포커스 행을 최소 스크롤로 화면에 두고 **보이는 행의 칸만** 계산한다. 터미널에 보이는 격자를 기억해 **바뀐 칸만** 보낸다. 크기 변경(SIGWINCH), frame 핸들러가 있으면 60 Hz | | CLI | `vega cli app.pmod < script` | 파이프용 평문. 목록은 `> ` 표시와 `[badge]`, 표는 표시 폭으로 정렬한 열. 터미널이고 `NO_COLOR`가 없을 때만 색 | | NUI | `vega say app.pmod < script` | 문장. 이벤트마다 포커스 항목을 말하고(`Item 2 of 3: danger, milk, urgent.`), 실패하면 `Could not toggle.`, `show`에서 화면 전체 | @@ -46,6 +46,17 @@ Tab이 키 모드로 바꾸면 글자·숫자·Space가 `KeyA`·`Digit0`·`Space 표시 폭은 Vega가 맡는다. 한글·CJK·그림 이모지는 두 칸, 결합 문자·변이 선택자·ZWJ는 0칸, 제어 문자는 제어 그림(U+2400대)으로 보인다. grapheme 단위 자르기는 아직 없어 ZWJ 이모지 묶음은 근사다. +## 셀 단위 갱신 + +TUI는 매 변경마다 격자를 앱에서 다시 그리지만(보이는 칸만 계산), 터미널에는 지금 보이는 것과 다른 칸만 보낸다. +바뀐 칸의 연속 구간마다 커서를 옮기고 그 구간만 쓴다. 짧은 무변경 틈(6칸 이하)은 커서 이동보다 싸므로 구간에 넣는다. +넓은 글자의 뒷칸에서 시작하는 구간은 앞칸부터 쓴다. 크기가 바뀌면 전체를 다시 보낸다. + +`vega tui app.pmod --paint WxH`는 스크립트의 `show`마다 터미널로 보낼 바이트를 낸다. 검사는 그 바이트를 작은 터미널 +에뮬레이터로 재생해 `--dump`의 화면과 프레임마다 비교한다(커서 이동, 지우기, 색·반전, 두 칸 글자, 결합 문자). +무작위 입력 4,800 프레임(40×12, 24×9, 80×30)에서 모두 같았다. 한 프레임이 보내는 바이트는 평균 1,220 B(전체)에서 +139 B(변경분)로 줄었다. SSH처럼 느린 터미널에서 입력 한 번의 화면 갱신이 그만큼 가볍다. + ## 규약 검사 (`vega check`) `vega check app.pmod`는 모듈이 뜻한 듯하지만 규약에 맞지 않는 곳을 알린다. 표면은 이런 보기를 조용히 다른 @@ -94,5 +105,5 @@ CLI나 문장 표면이 그대로 이어받는다. Vega에도 앱에도 저장 � - 포커스를 표면에서 바꾸기(GUI 클릭, TUI 마우스). 지금은 앱의 키 핸들러만 포커스를 옮긴다. Pure에 표의 인덱스를 이벤트로 바꾸는 호스트 API가 필요하다. -- 셀 단위 갱신(TUI는 매 변경마다 전체를 다시 그린다), GUI 표면, 음성 합성으로 가는 NUI. +- GUI 표면, 음성 합성으로 가는 NUI. - 문장의 언어. 지금 NUI의 틀 문장은 영어이고 값은 앱의 text 그대로다. @@ -192,10 +192,13 @@ void vega_say(FILE *out, PureApp *app, const VegaScreen *screen, bool all) { 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; + VegaCell *cells = calloc(count ? count : 1, sizeof(*cells)), *shown = calloc(count ? count : 1, sizeof(*shown)); + if (!cells || !shown) { free(cells); free(shown); return false; } free(grid->cells); + free(grid->shown); grid->cells = cells; + grid->shown = shown; + grid->painted = false; grid->width = width; grid->height = height; return true; @@ -203,6 +206,7 @@ bool vega_grid(VegaGrid *grid, unsigned width, unsigned height) { void vega_grid_free(VegaGrid *grid) { free(grid->cells); + free(grid->shown); *grid = (VegaGrid){0}; } @@ -14,6 +14,7 @@ * 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 * With --store the app's states live in that tree store: the app continues @@ -157,33 +158,56 @@ static void dump(const VegaGrid *grid) { 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); +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; - 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; + 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; } - out_codepoint(&out, line[c].ch); - if (line[c].mark) out_codepoint(&out, line[c].mark); + 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; } - 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 (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); } @@ -304,7 +328,7 @@ static int interactive(void) { else vega_grid(&grid, 80, 24); } vega_draw(&grid, &app, &screen, entry, entry_length, typing); - paint(&grid); + paint_terminal(&grid); struct pollfd wait = {STDIN_FILENO, POLLIN, 0}; int ready = poll(&wait, 1, ticking ? 16 : -1); if (ready == 0) { pure_app_frame(&app); continue; } @@ -322,7 +346,8 @@ static int interactive(void) { typedef enum { SURFACE_CLI, SURFACE_SAY, SURFACE_TUI } Surface; -static int script(Surface surface, unsigned width, unsigned height) { +/* 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; @@ -355,7 +380,15 @@ static int script(Surface surface, unsigned width, unsigned height) { acted = false; if (surface == SURFACE_CLI) vega_print(stdout, &app, &screen, color); else if (surface == SURFACE_SAY) vega_say(stdout, &app, &screen, true); - else { + else if (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); @@ -378,17 +411,18 @@ static int script(Surface surface, unsigned width, unsigned height) { int main(int argc, char **argv) { unsigned width = 0, height = 0; - bool dumped = false, usage = argc < 3 || argc % 2 == 0; + bool dumped = false, painting = false, usage = argc < 3 || argc % 2 == 0; const char *store = NULL; for (int i = 3; !usage && i + 1 < argc; i += 2) { if (!strcmp(argv[i], "--store")) store = argv[i + 1]; - else if (!strcmp(argv[i], "--dump") && sscanf(argv[i + 1], "%ux%u", &width, &height) == 2 && width && height && - width <= 1000 && height <= 1000) dumped = true; + 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] --dump WIDTHxHEIGHT < script\n" + "vega tui MODULE.pmod [--store PATH] --dump|--paint WIDTHxHEIGHT < script\n" "vega check MODULE.pmod\n", stderr); return 2; } @@ -400,7 +434,7 @@ int main(int argc, char **argv) { !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], store)) return 2; - int status = surface == SURFACE_TUI && !dumped ? interactive() : script(surface, width, height); + int status = 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); @@ -66,7 +66,9 @@ void vega_print(FILE *, PureApp *, const VegaScreen *, bool color); 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. */ + * 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; @@ -74,7 +76,8 @@ typedef struct { } VegaCell; typedef struct { unsigned width, height; - VegaCell *cells; + VegaCell *cells, *shown; + 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; diff --git a/tests/check.py b/tests/check.py index 8b86c71..d46698d 100644 --- a/tests/check.py +++ b/tests/check.py @@ -18,6 +18,8 @@ interactive session's entries are there in the next one. 6. vega check names every convention a spec seems to mean but misses, and none in the examples. +7. Painting: the bytes the TUI sends, replayed by a small terminal emulator, + give the very frame the dump shows, while sending only what changed. """ import argparse import json @@ -248,6 +250,97 @@ def tui(vega, module, seeds, sizes, steps=300): return checked +TONE_NAMES = ['normal', 'muted', 'accent', 'good', 'warning', 'danger'] + + +def replay(stream, screen): + """Apply terminal bytes to screen (rows of [text, tone, focus] cells) as a + terminal would: cursor moves, clearing, colors and reverse video, wide + characters over two cells, combining marks joining the character before.""" + text, row, col, tone, focus, i = stream.decode(), 0, 0, 0, False, 0 + while i < len(text): + ch = text[i] + if ch == '\x1b': + assert text[i + 1] == '[', repr(text[i:i + 8]) + j = i + 2 + while not '@' <= text[j] <= '~': + j += 1 + params, final = text[i + 2:j], text[j] + if final == 'H': + row, col = (int(n or 1) - 1 for n in (params.split(';') + ['1'])[:2]) + elif final == 'J': + for line in screen: + line[:] = [[' ', 0, False] for _ in line] + elif final == 'm': + codes = params.split(';') + focus = '7' in codes + tone = next((t for code, t in (('36', 2), ('32', 3), ('33', 4), ('31', 5), ('2', 1)) if code in codes), 0) + i = j + 1 + continue + cells = width(ch) + if not cells: + back = col - 1 + while back > 0 and screen[row][back][0] == '': + back -= 1 + screen[row][back][0] += ch + else: + screen[row][col] = [ch, tone, focus] + if cells == 2: + screen[row][col + 1] = ['', tone, focus] + col += cells + i += 1 + + +def shown(screen): + """A replayed screen in the dump's form.""" + lines = [] + for line in screen: + focus = any(cell[2] for cell in line) + text = ''.join(cell[0] for cell in line).rstrip(' ') + first = next((cell for cell in line if cell[0] not in ('', ' ')), None) + tone = first[1] if first else 0 + lines.append(('>' if focus else '|') + text + ('\t' + TONE_NAMES[tone] if tone else '')) + return lines + + +def painting(vega, module, seeds, sizes, steps=200): + """Every painted frame equals the dumped one; later frames send only what + changed. Returns frames compared and the mean bytes of a first and of a + later frame.""" + compared, first_bytes, later_bytes, later = 0, 0, 0, 0 + for columns, rows in sizes: + for seed in seeds: + rng = random.Random(seed * 13 + columns) + script = [] + for _ in range(steps): + roll = rng.random() + if roll < 0.25: script.append('text ' + (rng.choice(WORDS).encode().hex() or '-')) + elif roll < 0.3: script.append(rng.choice(['type 61', 'enter', 'tab'])) + else: script.append('key ' + rng.choice(list(KEYS.values()))) + script.append('show') + text = '\n'.join(script) + '\n' + dumped = [frame for frame, _ in frames(run(vega, 'tui', module, '--dump', f'{columns}x{rows}', stdin=text).stdout, + rows)] + painted = subprocess.run([str(vega), 'tui', str(module), '--paint', f'{columns}x{rows}'], input=text.encode(), + capture_output=True, timeout=120) + assert painted.returncode == 0, painted.stderr + out, at, screen, n = painted.stdout, 0, [[[' ', 0, False] for _ in range(columns)] for _ in range(rows)], 0 + while at < len(out): + end = out.index(b'\n', at) + size = int(out[at:end].split()[1]) + replay(out[end + 1:end + 1 + size], screen) + assert shown(screen) == dumped[n], (seed, n, shown(screen), dumped[n]) + if n: later_bytes, later = later_bytes + size, later + 1 + else: first_bytes += size + at, n = end + 2 + size, n + 1 + assert n == len(dumped), (n, len(dumped)) + compared += n + first_mean = first_bytes // (len(sizes) * len(seeds)) + later_mean = later_bytes // later + assert later_mean * 3 < first_mean, (first_mean, later_mean) + return compared, first_mean, later_mean + + def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] @@ -299,7 +392,11 @@ def interactive(vega, module, store=None): text = screen.decode(errors='replace') assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, status assert '\x1b[?1049h' in text and text.rstrip().endswith('\x1b[?1049l'), text[-80:] - assert 'milk' in text and 'béton' in text and '(2)' in text and 'done' in text, text[-400:] + # What the terminal ended up showing, from the bytes it was sent. + grid = [[[' ', 0, False] for _ in range(40)] for _ in range(10)] + replay(screen, grid) + final = '\n'.join(shown(grid)) + assert 'milk' in final and 'béton' in final and '(2)' in final and 'done' in final, final if store: # what was typed is still there in the next session again = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() assert again == ['To do', 'left: 2 tasks', 'tasks_list (2)', '> milk [done] (muted)', ' béton'], again @@ -384,7 +481,8 @@ def robustness(vega, aup, work, rounds): elif roll < 0.4: script.append(rng.choice(['type 61', 'enter', 'tab'])) else: script.append('key ' + rng.choice(keys)) text = '\n'.join(script) + '\nshow\n' - for surface in (['cli'], ['say'], ['tui', '--dump', rng.choice(['80x24', '12x5', '1x1', '200x3'])]): + size = rng.choice(['80x24', '12x5', '1x1', '200x3']) + for surface in (['cli'], ['say'], ['tui', '--dump', size], ['tui', '--paint', size]): result = run(vega, surface[0], module, *surface[1:], stdin=text) assert result.returncode == 0, (path, surface, result.stderr[-400:]) runs += 1 @@ -412,6 +510,8 @@ def main(): cli_and_say(vega, modules['todo'], range(args.seeds)) report['tui_frames'] = tui(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30), (16, 6)]) report['table_lines'] = scores(vega, modules['scores']) + report['painted_frames'], report['first_paint_bytes'], report['later_paint_bytes'] = \ + painting(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30)]) report['interactive_sessions'] = interactive(vega, modules['todo']) + \ interactive(vega, modules['todo'], work / 'interactive-store') report['stored_surface_lines'] = stored_surfaces(vega, modules['todo'], work, range(args.seeds)) |
