aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/001-first-component.md15
-rw-r--r--src/draw.c8
-rw-r--r--src/main.c96
-rw-r--r--src/vega.h7
-rw-r--r--tests/check.py104
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))