aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-26 17:58:26 +0100
committereunhhu <[email protected]>2026-09-26 17:58:26 +0100
commitb466603358f8fa5a74551a9819fdd54ad124ef0c (patch)
tree6f34265f30455589201222521c1a452274a0601d
parent15c4616e19d997c64a983e39501289c7d9f50d0a (diff)
downloadvega-b466603358f8fa5a74551a9819fdd54ad124ef0c.tar.gz
vega-b466603358f8fa5a74551a9819fdd54ad124ef0c.zip
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.
-rw-r--r--docs/design/001-first-component.md12
-rw-r--r--examples/stopwatch.aup43
-rw-r--r--src/main.c34
-rw-r--r--src/screen.c29
-rw-r--r--src/vega.h10
-rw-r--r--src/web.c17
-rw-r--r--tests/__pycache__/check.cpython-313.pycbin65599 -> 71152 bytes
-rw-r--r--tests/check.py68
8 files changed, 203 insertions, 10 deletions
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 <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;
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
--- a/tests/__pycache__/check.cpython-313.pyc
+++ b/tests/__pycache__/check.cpython-313.pyc
Binary files 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'] = \