From b466603358f8fa5a74551a9819fdd54ad124ef0c Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 17:58:26 +0100 Subject: Give live surfaces a clock and run timers; add a stopwatch The terminal and the web page set the time events see to the wall clock and tick an app's timers on time: each round ticks what is due, draws, then waits for input, a frame or the next tick, and a tick is news to every open page at once. Scripts have no clock: time MS sets the time the next events see and tick TIMER ticks, so an app that uses time replays exactly. A module swap reschedules the timers. examples/stopwatch.aup runs on every surface; the checks drive it by script, on a pseudo-terminal by the clock, and on the web page. --- docs/design/001-first-component.md | 12 +++++- examples/stopwatch.aup | 43 ++++++++++++++++++++ src/main.c | 34 +++++++++++++--- src/screen.c | 29 ++++++++++++++ src/vega.h | 10 +++++ src/web.c | 17 ++++++-- tests/__pycache__/check.cpython-313.pyc | Bin 65599 -> 71152 bytes tests/check.py | 68 +++++++++++++++++++++++++++++++- 8 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 examples/stopwatch.aup diff --git a/docs/design/001-first-component.md b/docs/design/001-first-component.md index 1da0fdf..f1db4d8 100644 --- a/docs/design/001-first-component.md +++ b/docs/design/001-first-component.md @@ -93,6 +93,14 @@ TUI는 매 변경마다 격자를 앱에서 다시 그리지만(보이는 칸만 무작위 입력 4,800 프레임(40×12, 24×9, 80×30)에서 모두 같았다. 한 프레임이 보내는 바이트는 평균 1,220 B(전체)에서 143 B(변경분)로 줄었다. SSH처럼 느린 터미널에서 입력 한 번의 화면 갱신이 그만큼 가볍다. +## 시간 + +`examples/stopwatch.aup`처럼 앱이 `now`와 타이머(`input tick = every 100 ms`)를 쓰면, 살아 있는 표면(TUI, 웹)이 시계를 +준다. 사람의 입력과 프레임은 그 순간의 벽시계 시각을 보고, 타이머는 제때 틱을 넣는다. 루프는 기한이 된 틱을 넣고, +그리고, 입력·프레임·다음 틱 중 먼저 오는 것을 기다린다. 틱이 있으면 웹은 그 바퀴에 열린 모든 페이지에 알린다. 스크립트 +표면(CLI, 문장, `--dump`)에는 시계가 없다. `time MS`가 다음 이벤트들이 볼 시각을 정하고, `tick 타이머`가 틱을 넣는다. +그래서 시간을 쓰는 앱도 스크립트로 정확히 재현된다. 모듈을 바꾸면(`--watch`) 타이머를 새 모듈 것으로 다시 맞춘다. + ## 고쳐 쓰는 동안 (`--watch`) ```sh @@ -155,13 +163,15 @@ CLI나 문장 표면이 그대로 이어받는다. Vega에도 앱에도 저장 - 가상 터미널에서 대화형 TUI를 실제로 띄워 입력·확정·키 모드·종료·터미널 복원을 확인한다. - 한 저장소를 CLI와 문장 표면이 번갈아 이어 쓰며(시드마다 7번) 매번 모델과 같은 목록을 보이고, 대화형 세션에서 입력한 것이 다음 실행에 남는다. -- aup와 Vega의 예제 14개를 세 표면에 무작위 입력으로 돌려 비정상 종료가 없다. sanitizer 빌드로도 같은 검사를 통과한다. +- aup와 Vega의 예제 15개를 세 표면에 무작위 입력으로 돌려 비정상 종료가 없다. sanitizer 빌드로도 같은 검사를 통과한다. - 규약을 여덟 가지로 어긴 명세에서 `vega check`가 아홉 곳을 모두 정확한 문장으로 알리고, 예제에서는 아무것도 알리지 않는다. - 걸러진 두 목록(`examples/today.aup`)의 CLI 출력이 무작위 추가·이동·완료·양쪽 목록 가리키기 1,600단계에서 모델과 같다(포커스는 그 항목을 가진 목록에만 있다). 웹 JSON은 다른 목록의 포커스를 null로 준다. - 무작위 스크립트에 `point`(범위 밖 포함)와 TUI `click`을 섞어도 CLI·문장·프레임이 모델과 같다. 가상 터미널에서 마우스로 행을 클릭하면 포커스가 옮겨지고, 저장소를 쓰는 다음 실행이 그 포커스에서 시작한다. +- 시간: 스톱워치가 스크립트의 시각에서 정확한 줄을 내고(시작, 틱, 랩, 멈춤, 초기화), 가상 터미널에서는 실제 시계로 1초 + 남짓 흐른 시간을 보이며, 웹에서는 1초 동안 열 번 넘게 틱 알림이 온다. - 모듈 교체: 스크립트의 `swap`, 웹의 `--watch`(새 제목과 필드, 같은 항목, 모든 페이지에 알림), 1초 안에 같은 크기로 다시 쓴 모듈, 깨진 모듈의 거절, 가상 터미널 TUI의 `--watch`(포커스와 완료 표시 유지)를 확인한다. diff --git a/examples/stopwatch.aup b/examples/stopwatch.aup new file mode 100644 index 0000000..cd075b0 --- /dev/null +++ b/examples/stopwatch.aup @@ -0,0 +1,43 @@ +# A stopwatch. Time is an input: a timer ticks while a surface runs the app, +# and each tick sees the time it happened (now); the views only show states. +use vega + +record Lap { + at = 0 +} + +state running = 0 +state started = 0 +state elapsed = 0 +state laps: list of Lap +state at: index of laps + +input toggle = key Space +input lap = key KeyL +input reset = key KeyR +input tick = every 100 ms + +fn two(n) = if n < 10 { "0{n}" } else { "{n}" } +fn tenths(ms) = "{floor(ms / 60000)}:{two(floor(ms / 1000) % 60)}.{floor(ms / 100) % 10}" + +on toggle.down { + if running { elapsed = now - started } + running = !running + if running { started = now - elapsed } +} +on tick { if running { elapsed = now - started } } +on lap.down { + laps.append(Lap { at: elapsed }) + at = laps.last +} +on reset.down { if !running { elapsed = 0 } } + +view title = "Stopwatch" +view time = tenths(elapsed) +view status = if running { "running" } else { "stopped" } + +view laps_list = each l in laps { + label = tenths(l.at) + tone = vega.when(l.position == laps.count - 1, vega.accent) +} +view laps_list_focus = at.position diff --git a/src/main.c b/src/main.c index ebda312..4a313e9 100644 --- a/src/main.c +++ b/src/main.c @@ -25,12 +25,13 @@ * 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 | swap MODULE | show. key presses and + * 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); swap replaces the module as --watch - * does. */ + * 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; @@ -405,6 +406,8 @@ static int interactive(void) { 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; @@ -413,13 +416,19 @@ static int interactive(void) { 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 ready = poll(&wait, 1, screen.ticks ? 16 : watched ? 200 : -1); + 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); - module_changed(); + if (module_changed()) vega_timers_start(&timers, app.module, app.now); continue; } if (ready < 0) continue; /* a signal, such as a resize */ @@ -480,6 +489,21 @@ static int script(Surface surface, unsigned width, unsigned height, bool paintin 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"); diff --git a/src/screen.c b/src/screen.c index 56c2cf9..fdecaf4 100644 --- a/src/screen.c +++ b/src/screen.c @@ -1,7 +1,36 @@ +#define _POSIX_C_SOURCE 200809L #include "vega.h" #include "module-text.h" #include #include +#include + +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; diff --git a/src/vega.h b/src/vega.h index 47e7e91..f01667b 100644 --- a/src/vega.h +++ b/src/vega.h @@ -36,6 +36,16 @@ typedef struct { } 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; diff --git a/src/web.c b/src/web.c index 8202815..535d92f 100644 --- a/src/web.c +++ b/src/web.c @@ -382,7 +382,12 @@ int vega_web(PureApp *app, const VegaScreen *screen, const char *listen_on, unsi 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}; @@ -392,11 +397,17 @@ int vega_web(PureApp *app, const VegaScreen *screen, const char *listen_on, unsi fds[count] = (struct pollfd){c->fd, (short)(POLLIN | (c->out_at < c->out.used ? POLLOUT : 0)), 0}; slot[count++] = i; } - int ready = poll(fds, count, screen->ticks ? 33 : reload ? 200 : 1000); + 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 = false; + 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()) changed = true; /* a new module: every page draws it */ + 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; diff --git a/tests/__pycache__/check.cpython-313.pyc b/tests/__pycache__/check.cpython-313.pyc index 04b698b..9776a90 100644 Binary files a/tests/__pycache__/check.cpython-313.pyc and b/tests/__pycache__/check.cpython-313.pyc differ diff --git a/tests/check.py b/tests/check.py index a49a2a0..ff531fe 100644 --- a/tests/check.py +++ b/tests/check.py @@ -609,6 +609,71 @@ def hot_swap(vega, aup, work): return len(out) +def timing(vega, module): + """examples/stopwatch.aup: time is an input. A script sets the time events + see and ticks the timer; the terminal and the web page tick it on the + clock, and every page hears of each tick.""" + import fcntl, pty, select, struct, termios, time + script = ('time 1000\nkey Space\ntime 2500\ntick tick\nshow\ntime 63400\ntick tick\nkey KeyL\n' + 'time 70000\nkey Space\ntick tick\nshow\ntime 71000\nkey KeyR\nshow\n') + out = run(vega, 'cli', module, stdin=script).stdout.splitlines() + assert out == ['Stopwatch', 'time: 0:01.5', 'status: running', 'laps_list (0)', + 'Stopwatch', 'time: 1:09.0', 'status: stopped', 'laps_list (1)', '> 1:02.4 (accent)', + 'Stopwatch', 'time: 0:00.0', 'status: stopped', 'laps_list (1)', '> 1:02.4 (accent)'], out + # The terminal, on the clock: start it, and a second and more later it shows that. + pid, fd = pty.fork() + if pid == 0: + os.execv(str(vega), [str(vega), 'tui', str(module)]) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 8, 30, 0, 0)) + os.kill(pid, 28) + screen = b'' + + def read_for(seconds): + nonlocal screen + end = time.time() + seconds + while time.time() < end: + if select.select([fd], [], [], 0.05)[0]: + try: + screen += os.read(fd, 65536) + except OSError: + break + + read_for(0.3) + os.write(fd, b' ') + read_for(1.4) + os.write(fd, b'\x03') + read_for(0.3) + _, status = os.waitpid(pid, 0) + os.close(fd) + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, status + grid = [[[' ', 0, False] for _ in range(30)] for _ in range(8)] + replay(screen, grid) + final = '\n'.join(shown(grid)) + import re + seconds = float(re.search(r'time\s+0:(\d\d\.\d)', final).group(1)) + assert 1.2 <= seconds <= 2.5 and 'running' in final, final + # The web page: every tick is news to every open page. + surface = WebSurface(vega, module) + try: + import http.client + stream = http.client.HTTPConnection('127.0.0.1', surface.port, timeout=10) + stream.request('GET', '/events') + events = stream.getresponse() + events.readline(), events.readline() + surface.call('POST', '/key', 'Space') + heard = 0 + began = time.time() + while time.time() - began < 1.0: + if events.readline().startswith(b'data:'): heard += 1 + page = json.loads(surface.call('GET', '/screen')[1]) + field = next(f['text'] for f in page['fields'] if f['name'] == 'time') + assert heard >= 6 and float(field.split(':')[1]) >= 0.8, (heard, field) + stream.close() + finally: + surface.stop() + return len(out), heard + + def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] @@ -810,7 +875,7 @@ def main(): work = Path(directory) env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) modules = {} - for name in ('todo', 'scores', 'today'): + for name in ('todo', 'scores', 'today', 'stopwatch'): modules[name] = work / f'{name}.pmod' built = run(aup, 'build', ROOT / 'examples' / f'{name}.aup', '-o', modules[name], env=env) assert built.returncode == 0, built.stderr @@ -818,6 +883,7 @@ 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['timed_lines'], report['ticks_heard'] = timing(vega, modules['stopwatch']) report['hot_swap_lines'] = hot_swap(vega, aup, work) report['split_list_lines'] = split(vega, modules['today'], work, range(args.seeds)) report['painted_frames'], report['first_paint_bytes'], report['later_paint_bytes'] = \ -- cgit v1.2.3