From 04e883a310fd3914d6f4294a92b08b3fe9f55686 Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 10:52:12 +0100 Subject: Send the terminal only the cells that changed The TUI rebuilt its grid from the app on every change and then sent all of it. It now keeps what the terminal shows and sends each run of changed cells after a cursor move (short unchanged gaps stay in a run, being cheaper than another move; a run that starts in the second half of a wide character starts at its first). A resize sends everything again. vega tui --paint WxH writes the bytes of each frame; the checks replay them with a small terminal emulator and compare with the dumped frame: 4,800 random frames at three sizes agree, and a change costs 139 bytes on average against 1,220 for a whole frame. The interactive check now reads the final screen from the replayed bytes. --- docs/design/001-first-component.md | 15 +++++- src/draw.c | 8 ++- src/main.c | 96 +++++++++++++++++++++++----------- src/vega.h | 7 ++- 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 그대로다. diff --git a/src/draw.c b/src/draw.c index cf4e227..bec3804 100644 --- a/src/draw.c +++ b/src/draw.c @@ -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}; } diff --git a/src/main.c b/src/main.c index 3fcf105..4f83780 100644 --- a/src/main.c +++ b/src/main.c @@ -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); diff --git a/src/vega.h b/src/vega.h index 062639b..2d0a29f 100644 --- a/src/vega.h +++ b/src/vega.h @@ -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)) -- cgit v1.2.3