aboutsummaryrefslogtreecommitdiffstats
path: root/docs/design/001-first-component.md
blob: 387e7ad66f133e03b84bc1bbc36abdc4edf72e04 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# 001 — 첫 컴포넌트: 목록·표·포커스·톤, 표면 셋

2026-09-25. 상태: 구현(`surfaces/`, `lib/vega.aup`, `examples/`).

## 결정

Vega의 명세는 **새 언어나 위젯 트리가 아니라 aup 규약**이다. 앱은 aup로 상태와 보기를 선언하고,
Vega는 검증된 Pure 모듈의 보기를 이름과 타입으로 읽어 인터페이스 부품으로 해석한다. 인터페이스용
화면 모델을 따로 만들지 않고, 표면은 Pure에 보이는 칸만 물어 그린다.

| 모듈에 있는 것 | Vega가 읽는 부품 |
|---|---|
| text 보기 `title` | 화면 제목 |
| `label` text 열이 있는 표 보기 | **목록**. 행마다 `label`, 선택적으로 `badge`(text), `detail`(text), `tone`(수) |
| `label` 열이 없는 표 보기 | **표**. `tone`을 뺀 모든 열이 차례로 보인다 |
| 수 보기 `<표 이름>_focus` | 그 목록·표의 포커스 행(0부터) |
| 그 밖의 수·text 보기 | **필드**. 이름과 값 |
| 키 입력과 그 핸들러 | 표면이 보여 주는 키 목록 |
| 첫 text 입력 | 입력 줄(TUI), 스크립트의 `text` 명령 |

톤은 의미다(`lib/vega.aup`: `normal muted accent good warning danger`, `vega.when(조건, 톤)`). 색과 소리는 표면이
정한다. 지원하지 않는 표현은 다른 의미로 바꾸지 않고 드러낸다. 색이 없는 CLI는 톤을 `(danger)`처럼 글자로 적는다.

```text
use vega
view tasks_list = each task in tasks {
  label = task.title
  badge = if task.done { "done" } else if task.urgent { "urgent" } else { "" }
  tone = if task.done { vega.muted } else { vega.when(task.urgent, vega.danger) }
}
view tasks_list_focus = at.position
```

## 표면

| 표면 | 명령 | 하는 일 |
|---|---|---|
| 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`에서 화면 전체 |
| GUI | `vega web app.pmod [--listen 주소] [--port N]` | 웹 페이지. 제목·필드·목록(`listbox`)·표·키 목록·입력 칸. 키, 행 클릭, 휠, 입력을 받고 열린 페이지마다 변경을 알린다. 보이는 행만 계산한다 |

목록은 걸러질 수 있다(`each t in tasks where !t.done`). 한 리스트에서 걸러진 목록 여럿이 포커스 하나를 나눠 쓴다.
포커스 보기(`open_focus = at.position`)는 항목을 가리키므로 Vega는 Pure에게 그 항목의 행을 묻고
(`pure_app_item_row`), 그 항목을 가진 목록에만 포커스를 보인다. 다른 목록에는 포커스가 없다(웹 JSON의 `focus`는
null, 음성은 포커스를 가진 목록의 항목을 말한다). 행을 클릭하거나 가리키면 그 행의 항목을 가리킨다
(`pure_app_row_item`). `examples/today.aup`가 열린 일과 끝난 일 두 목록을 이렇게 보인다.

포커스는 표면에서도 옮긴다. TUI에서 목록·표의 행을 클릭하면 그 행이 포커스가 되고, 휠은 포커스를 한 행씩 옮긴다
(SGR 마우스 보고). 스크립트는 `point 보기 행`(0부터)과 `click 줄 칸`(마지막으로 보인 프레임, 1부터)으로 같은 일을 한다.
앱에 코드가 필요 없다. `<표>_focus` 보기가 `at.position`처럼 인덱스 상태의 위치 그대로이면 Vega는 Pure에게 그 상태를
묻고(`pure_module_position`) 행을 가리키는 이벤트로 옮긴다(`pure_app_point`). 키 이벤트처럼 저장되므로 다음 실행의 다른
표면도 그 포커스에서 이어 간다. 포커스 보기가 다른 식이면(계산한 위치 등) 가리킬 상태가 없어 클릭은 아무 일도 하지 않는다.

TUI 입력: 앱에 text 입력이 있으면 처음에는 글자가 입력 줄로 가고 Enter가 확정한다(IME 확정분과 같은 의미).
Tab이 키 모드로 바꾸면 글자·숫자·Space가 `KeyA`·`Digit0`·`Space` 입력이 된다(앱이 Tab을 쓰면 Tab은 앱의 것).
화살표·Delete·Home 같은 키는 두 모드 모두 앱으로 간다. Ctrl-C나 Ctrl-Q로 끝내고 터미널을 되돌린다.

표시 폭은 Vega가 맡는다. 한글·CJK·그림 이모지는 두 칸, 결합 문자·변이 선택자·ZWJ는 0칸이다. grapheme 단위
자르기는 아직 없어 ZWJ 이모지 묶음은 근사다.

**값이 보이는 방식.** 값은 제 자리 안에서 제 글자로 보인다. 화면에 작용할 글자만 예외다(Pure
`docs/design/015-boundary-meaning.md`가 정한 값의 뜻의 화면 쪽이다).

- 수는 Pure의 가장 짧은 글자로, bool은 `true`나 `false`로, i64는 모든 자릿수로(2^53을 넘어도) 보인다. 보간(`"{done}"`)과
  같은 글자다. 다르게 보이고 싶은 앱은 조건식으로 글을 고른다(`if t.done { "완료" } else { "" }`).

- 제어 문자(C0)와 DEL은 제어 그림(␀…␟, ␡)으로, C1 제어는 �로, 줄과 문단의 구분자(U+2028, U+2029)는 ␤로 보인다
  (`vega_visible`). CLI, TUI, 픽셀, 웹 페이지가 모두 같다. 값은 제 줄을 끊지 못한다.
- 터미널의 칸(TUI, CLI)에서는 뒤따르는 글줄을 뒤집을 제어(방향 임베딩·오버라이드·격리, U+202A–202E, U+2066–2069)도
  �로 보인다(`vega_cell`). 터미널은 그 뒤의 칸까지 제 방식으로 다시 늘어놓기 때문이다. 픽셀은 Vega가 값의 자리 안에서
  양방향을 처리하고, 웹 페이지는 값마다 격리한다(`unicode-bidi: isolate`).
- 문장(NUI)은 작용할 글자를 공백으로 말하고, 방향 제어는 말하지 않는다.
- 웹의 `/screen` JSON은 값을 그대로 싣는다(API는 잃지 않는다). 페이지가 위의 규칙대로 보인다.
- 그래서 값의 어떤 바이트도 터미널에 제어로 닿지 않는다. 다른 노드가 보낸 글(메신저의 줄)로도 받는 쪽의 화면을
  조작하지 못한다.
- 픽셀에서 방향 제어가 든 값은 칸 너비가 한 칸 넓게 잡힌다(칸 너비는 `vega_cell`로 센다). 드물고, 겉모양만의 일이다.

`tests/check.py`의 `screen_meaning`이 CLI, TUI, 문장, 웹 JSON을 이 규칙으로 보고, 어느 표면에도 작용할 글자가 날로
나가지 않는지 본다. `browser`가 페이지의 그림과 격리를 본다.

## 웹 (GUI)

`vega web app.pmod`는 같은 명세를 브라우저에 보인다. 기본은 이 기기(127.0.0.1)에서만 받는다. 다른 기기에서 열려면
`--listen 0.0.0.0`처럼 주소를 준다. 앱이 곧 서버의 상태이므로 여러 탭이 한 앱을 함께 본다.

| 요청 | 하는 일 |
|---|---|
| `GET /` | 페이지 하나(HTML·CSS·JS, 의존성 없음, 5 KB). 밝은·어두운 모드, 톤은 색, 포커스는 강조 |
| `GET /screen?rows=R&first=F,…` | 화면 JSON. 목록·표마다 마지막 첫 행 F에서 R행만, TUI와 같은 최소 스크롤로 포커스를 창 안에 둔다 |
| `POST /key` `/text` `/point` | 키 누름·뗌, 확정된 글, 행 가리키기(`파트 행`) |
| `GET /events` | 서버 전송 이벤트. 앱이 바뀔 때마다 열린 페이지 모두에 한 번 |

페이지의 키는 브라우저의 `KeyboardEvent.code`를 그대로 보낸다. Pure의 키 이름(`ArrowUp`, `KeyA`, `Space`, `Digit1`)이 같은
이름 체계라 변환표가 없다. 입력 칸에서는 글이 칸으로 가고 Enter가 확정한다. 위아래 화살표와 PageUp·PageDown은 앱으로 간다.
행을 클릭하면 그 행이, 휠을 굴리면 포커스가 한 행씩 옮겨 간다(`pure_app_point`). 목록은 `listbox`와 `aria-selected`로
그려 화면 낭독기가 포커스를 읽는다.

검증: 무작위 키·글·행 가리키기 1,200단계에서 화면 JSON(제목, 필드, 행 수, 포커스, 창의 행과 배지·톤)이 모델과 같고,
열린 이벤트 스트림이 변경을 듣고, 저장소가 다음 실행으로 이어진다. 헤드리스 Chromium을 DevTools 프로토콜로 움직여
페이지 자체도 확인한다(`tests/browser.mjs`). ArrowUp이 포커스를 옮기고, 행 클릭이 그 행을 고르고, Space가 완료(흐린 톤,
배지)를 표시하고, 한글을 입력하고 Enter를 치면 새 항목이 생겨 포커스를 받는다. 모든 예제에 무작위 요청을 보내도 응답이
올바르다.

## 셀 단위 갱신

TUI는 매 변경마다 격자를 앱에서 다시 그리지만(보이는 칸만 계산), 터미널에는 지금 보이는 것과 다른 칸만 보낸다.
바뀐 칸의 연속 구간마다 커서를 옮기고 그 구간만 쓴다. 짧은 무변경 틈(6칸 이하)은 커서 이동보다 싸므로 구간에 넣는다.
넓은 글자의 뒷칸에서 시작하는 구간은 앞칸부터 쓴다. 크기가 바뀌면 전체를 다시 보낸다.

`vega tui app.pmod --paint WxH`는 스크립트의 `show`마다 터미널로 보낼 바이트를 낸다. 검사는 그 바이트를 작은 터미널
에뮬레이터로 재생해 `--dump`의 화면과 프레임마다 비교한다(커서 이동, 지우기, 색·반전, 두 칸 글자, 결합 문자).
무작위 입력 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
AUP_PATH=lib aup watch app.aup -o app.pmod &   # 저장할 때마다 다시 빌드
vega tui app.pmod --watch --store ~/.app        # 또는 vega web app.pmod --watch
```

`--watch`(tui, web)는 모듈 파일이 다시 쓰이면 실행 중인 앱의 모듈을 새것으로 바꾼다. 상태는 Pure의
`pure_app_replace`가 이름으로 이어받는다. 목록의 항목은 필드 이름으로, 포커스와 완료 표시는 그대로 남고, 새 상태는
초기값으로 시작한다. 화면(제목·필드·목록·입력줄)은 새 모듈에서 다시 읽고, 웹은 열린 모든 페이지에 알린다.
스크립트에서는 `swap MODULE`이 같은 일을 한다.

- **바이트로 본다.** 파일 시각이 아니라 읽은 바이트의 digest를 비교한다. 1초 안에 같은 크기로 다시 쓴 모듈도
  놓치지 않는다. 확인은 초당 다섯 번까지이고, 모듈 하나(수 KB)를 읽는 비용이다.
- **로드되지 않는 모듈은 거절한다.** 검증을 통과하지 못하거나 바꿀 수 없는 모듈이면 실행 중인 앱을 그대로 두고
  stderr에 이유를 한 번 쓴다. 같은 바이트는 다시 시도하지 않는다. aup는 모듈을 임시 파일에 쓰고 rename하므로 반쯤
  쓰인 모듈을 읽는 일이 없다. 다른 도구가 제자리에 쓰면 읽다 만 모듈은 검증에서 거절되고, 다 쓴 모듈이 다음
  확인에서 들어온다.
- **두 칸.** 모듈은 두 칸을 번갈아 쓴다. 새 모듈을 빈 칸에 로드하고 바꾸는 데 성공해야 그 칸이 실행 칸이 된다.
  그래서 실패한 로드가 실행 중인 모듈을 건드리지 않는다. 로드된 모듈은 파일 바이트를 붙잡지 않으므로 읽기 버퍼는
  하나다.

## 규약 검사 (`vega check`)

`vega check app.pmod`는 모듈이 뜻한 듯하지만 규약에 맞지 않는 곳을 알린다. 표면은 이런 보기를 조용히 다른
부품으로 읽는다. 검사는 그 읽기를 드러내어 오타나 타입 실수가 화면에서 사라지지 않게 한다. 찾으면 종료 코드 1이다.
규칙은 화면을 읽는 코드와 같은 파일(`surfaces/screen.c`)에 있다.

| 찾는 것 | 표면이 대신 하는 일 |
|---|---|
| 수인 `title` | 필드로 보인다 |
| 수인 `label` 열 | 목록이 아니라 표로 보인다 |
| text인 `tone` 열 | 목록은 무시, 표는 열로 보인다 |
| 수인 `badge`·`detail` 열 | 보이지 않는다 |
| 목록의 그 밖의 열 | 보이지 않는다(목록은 label, badge, detail, tone만 보인다) |
| 보일 열이 없는 표 | 제목과 행 수만 보인다 |
| 목록·표가 없는 `X_focus`, text인 `_focus` | 필드로 보이거나 포커스가 없다 |
| 두 번째 text 입력 | 입력할 곳이 없다(첫 번째만 입력 줄을 얻는다) |

```text
$ vega check bad.pmod
bad.pmod: items_list.tone: a tone is a number (vega.normal ... vega.danger); the list ignores it
bad.pmod: missing_focus: names no list or table missing; it shows as a field
```

## 영속

모든 표면은 `--store 경로`를 받는다. 앱의 상태는 Pure가 그 트리 저장소에 두므로, 터미널에서 적은 할 일을 다음 실행의
CLI나 문장 표면이 그대로 이어받는다. Vega에도 앱에도 저장 코드가 없다([Pure 008](/srv/git/pure.git) 상태 영속).
끝날 때 frame 변경까지 저장한다.

## 검증 (`make check`)

- `examples/todo.aup`에 무작위 추가·이동·토글·긴급·삭제 스크립트를 주고, CLI 출력과 NUI 문장이 앱과 Vega 규약의
  Python 참조 모델과 줄 단위로 같다(8시드, 약 8,000줄, 빈 목록에서 실패하는 이벤트 약 200건 포함).
- TUI 프레임 9,600개(40×12, 24×9, 80×30, 16×6)에서 보이는 행 범위와 최소 스크롤, 포커스 행, 마지막 열에서 끝나는 배지,
  말줄임, 행 톤이 모델과 같다. 프레임마다 계산한 칸 수가 `제목 + 필드 + 포커스 2회 + 보이는 행 × 3`과 정확히 같다.
  2,000항목 목록도 한 프레임에 25칸만 계산한다.
- `examples/scores.aup`의 표가 표시 폭으로 정렬되고(한글·결합 문자 포함) 행 톤이 표시된다.
- 가상 터미널에서 대화형 TUI를 실제로 띄워 입력·확정·키 모드·종료·터미널 복원을 확인한다.
- 한 저장소를 CLI와 문장 표면이 번갈아 이어 쓰며(시드마다 7번) 매번 모델과 같은 목록을 보이고, 대화형 세션에서 입력한
  것이 다음 실행에 남는다.
- aup와 Vega의 예제 16개를 세 표면에 무작위 입력으로 돌려 비정상 종료가 없다. sanitizer 빌드로도 같은 검사를 통과한다.
- 규약을 여덟 가지로 어긴 명세에서 `vega check`가 아홉 곳을 모두 정확한 문장으로 알리고, 예제에서는 아무것도 알리지
  않는다.
- 걸러진 두 목록(`examples/today.aup`)의 CLI 출력이 무작위 추가·이동·완료·양쪽 목록 가리키기 1,600단계에서 모델과
  같다(포커스는 그 항목을 가진 목록에만 있다). 웹 JSON은 다른 목록의 포커스를 null로 준다.
- 무작위 스크립트에 `point`(범위 밖 포함)와 TUI `click`을 섞어도 CLI·문장·프레임이 모델과 같다. 가상 터미널에서
  마우스로 행을 클릭하면 포커스가 옮겨지고, 저장소를 쓰는 다음 실행이 그 포커스에서 시작한다.
- 호스트 사이: 보드(`examples/board.aup`)를 CLI가 저장소에 쓰고, 같은 저장소를 `pure-serve`가 JSON API로 서빙하며
  (프로그램이 카드를 옮기고 더한다), 다시 연 CLI가 그 변경과 같은 카드에 머문 포커스를 보인다.
- 시간: 스톱워치가 스크립트의 시각에서 정확한 줄을 내고(시작, 틱, 랩, 멈춤, 초기화), 가상 터미널에서는 실제 시계로 1초
  남짓 흐른 시간을 보이며, 웹에서는 1초 동안 열 번 넘게 틱 알림이 온다.
- 모듈 교체: 스크립트의 `swap`, 웹의 `--watch`(새 제목과 필드, 같은 항목, 모든 페이지에 알림), 1초 안에 같은 크기로
  다시 쓴 모듈, 깨진 모듈의 거절, 가상 터미널 TUI의 `--watch`(포커스와 완료 표시 유지)를 확인한다.

## 남은 것

- 음성 합성으로 가는 NUI, 창이 있는 데스크톱 GUI(지금 GUI는 브라우저다).
- 문장의 언어. 지금 NUI의 틀 문장은 영어이고 값은 앱의 text 그대로다.