diff options
| author | eunhhu <[email protected]> | 2026-09-26 11:33:31 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-26 11:33:31 +0100 |
| commit | 0d5effd6afe8808296a01db102ca4c247cdcd1af (patch) | |
| tree | 4f1113880606fb6dda861fd846f8ff40d5c17a63 | |
| parent | 3b3e4a221fa47d0109c6ab99be65b5ba48bae254 (diff) | |
| download | vega-0d5effd6afe8808296a01db102ca4c247cdcd1af.tar.gz vega-0d5effd6afe8808296a01db102ca4c247cdcd1af.zip | |
Show filtered lists and their shared focus
A list may now be filtered (each t in tasks where !t.done), and several
filtered lists over one list share one focus: the focus view names an
item, so Vega asks Pure which row shows it and highlights it only in the
list that holds it; the other lists have no focus (null in the web
surface's JSON, and the spoken surface names the item where it is). A
click or point on a row points at the item that row shows, and the wheel
on a list without the focus focuses the row under it. Pure's rows now
take a mutable app.
examples/today.aup shows what is open and what is done; its CLI output
matches a model over random adds, moves, toggles and points into either
list, and the web surface leaves the other list without a focus.
| -rw-r--r-- | docs/design/001-first-component.md | 8 | ||||
| -rw-r--r-- | examples/today.aup | 39 | ||||
| -rw-r--r-- | src/draw.c | 12 | ||||
| -rw-r--r-- | src/main.c | 6 | ||||
| -rw-r--r-- | src/screen.c | 17 | ||||
| -rw-r--r-- | src/vega.h | 10 | ||||
| -rw-r--r-- | src/web.c | 10 | ||||
| -rw-r--r-- | tests/check.py | 56 |
8 files changed, 139 insertions, 19 deletions
diff --git a/docs/design/001-first-component.md b/docs/design/001-first-component.md index df462e5..50a516d 100644 --- a/docs/design/001-first-component.md +++ b/docs/design/001-first-component.md @@ -40,6 +40,12 @@ view tasks_list_focus = at.position | 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에게 그 상태를 @@ -130,6 +136,8 @@ CLI나 문장 표면이 그대로 이어받는다. Vega에도 앱에도 저장 � - aup와 Vega의 예제 11개를 세 표면에 무작위 입력으로 돌려 비정상 종료가 없다. sanitizer 빌드로도 같은 검사를 통과한다. - 규약을 여덟 가지로 어긴 명세에서 `vega check`가 아홉 곳을 모두 정확한 문장으로 알리고, 예제에서는 아무것도 알리지 않는다. +- 걸러진 두 목록(`examples/today.aup`)의 CLI 출력이 무작위 추가·이동·완료·양쪽 목록 가리키기 1,600단계에서 모델과 + 같다(포커스는 그 항목을 가진 목록에만 있다). 웹 JSON은 다른 목록의 포커스를 null로 준다. - 무작위 스크립트에 `point`(범위 밖 포함)와 TUI `click`을 섞어도 CLI·문장·프레임이 모델과 같다. 가상 터미널에서 마우스로 행을 클릭하면 포커스가 옮겨지고, 저장소를 쓰는 다음 실행이 그 포커스에서 시작한다. diff --git a/examples/today.aup b/examples/today.aup new file mode 100644 index 0000000..e5db53b --- /dev/null +++ b/examples/today.aup @@ -0,0 +1,39 @@ +# Two lists over one list state: what is open and what is done. Each shows +# the items its where keeps; the focus (at) is one item, shown in whichever +# list holds it, and a click in either list points at that item. +use vega + +record Task { + title: text + done = 0 +} + +state tasks: list of Task +state at: index of tasks + +input add = text +input up = key ArrowUp +input down = key ArrowDown +input toggle = key Space + +on add { + tasks.append(Task { title: add }) + at = tasks.last +} +on up.down { at = at - 1 } +on down.down { at = at + 1 } +on toggle.down { at.done = !at.done } + +view title = "Today" +view left = "{count(t in tasks where !t.done)} open" + +view open = each t in tasks where !t.done { + label = t.title +} +view open_focus = at.position + +view done = each t in tasks where t.done { + label = t.title + tone = vega.muted +} +view done_focus = at.position @@ -138,6 +138,7 @@ static void say_focus(FILE *out, PureApp *app, const VegaPart *part) { size_t length, rows = vega_rows(app, part); if (!rows) { fprintf(out, "%s is empty.\n", m->view_names[part->view]); return; } size_t focus = vega_focus(app, part); + if (focus >= rows) { fprintf(out, "%s: none of %zu focused.\n", m->view_names[part->view], rows); return; } fprintf(out, "Item %zu of %zu: ", focus + 1, rows); VegaTone tone = vega_tone(app, part, focus); if (tone >= VEGA_ACCENT) fprintf(out, "%s, ", tone_names[tone]); @@ -164,8 +165,15 @@ void vega_say(FILE *out, PureApp *app, const VegaScreen *screen, bool all) { char text[VEGA_TEXT]; size_t length; if (!all) { - for (unsigned i = 0; i < screen->count; ++i) - if (screen->parts[i].kind != VEGA_FIELD) { say_focus(out, app, &screen->parts[i]); return; } + /* The focused item where a list shows it, else the first list. */ + const VegaPart *first = NULL; + for (unsigned i = 0; i < screen->count; ++i) { + const VegaPart *part = &screen->parts[i]; + if (part->kind == VEGA_FIELD) continue; + if (!first) first = part; + if (vega_focus(app, part) < vega_rows(app, part)) { say_focus(out, app, part); return; } + } + if (first) say_focus(out, app, first); return; } if (screen->title >= 0 && vega_text(app, (uint32_t)screen->title, 0, text, &length)) { @@ -95,8 +95,10 @@ static bool mouse(unsigned button, unsigned line) { const VegaPart *part = &screen.parts[index]; if (button == 64 || button == 65) { size_t focus = vega_focus(&app, part), rows = vega_rows(&app, part); - if (button == 64 ? !focus : focus + 1 >= rows) return false; - item = button == 64 ? focus - 1 : focus + 1; + if (focus < rows) { /* else the wheel focuses the row under it */ + if (button == 64 ? !focus : focus + 1 >= rows) return false; + item = button == 64 ? focus - 1 : focus + 1; + } } else if (button) return false; return vega_point(&app, part, item); } diff --git a/src/screen.c b/src/screen.c index bb50de1..26b9fb0 100644 --- a/src/screen.c +++ b/src/screen.c @@ -146,20 +146,23 @@ bool vega_number(PureApp *app, uint32_t view, size_t row, double *out) { return app->module->view_table[view] ? pure_app_cell(app, view, row, out) : pure_app_view(app, view, out); } -size_t vega_rows(const PureApp *app, const VegaPart *part) { +size_t vega_rows(PureApp *app, const VegaPart *part) { return part->kind == VEGA_FIELD ? 0 : pure_app_rows(app, part->view); } size_t vega_focus(PureApp *app, const VegaPart *part) { - size_t rows = vega_rows(app, part); + size_t rows = vega_rows(app, part), row; double focus; if (part->focus < 0 || !rows || !vega_number(app, (uint32_t)part->focus, 0, &focus) || !(focus >= 0)) return 0; - return focus >= (double)(rows - 1) ? rows - 1 : (size_t)focus; + bool filtered = app->module->view_code[part->view].length > 0; + size_t item = focus < (double)SIZE_MAX ? (size_t)focus : SIZE_MAX; + if (pure_app_item_row(app, part->view, item, &row)) return row; + return filtered ? VEGA_NO_ROW : rows - 1; } size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows) { - if (focus < first) first = focus; - if (visible && focus >= first + visible) first = focus - visible + 1; + if (focus < rows && focus < first) first = focus; + if (focus < rows && visible && focus >= first + visible) first = focus - visible + 1; if (first + visible > rows) first = rows > visible ? rows - visible : 0; return first; } @@ -184,7 +187,9 @@ bool vega_commit(PureApp *app, const VegaScreen *screen, const char *text, size_ } bool vega_point(PureApp *app, const VegaPart *part, size_t row) { - return part->pointer >= 0 && pure_app_point(app, (uint32_t)part->pointer, row) == PURE_OK; + size_t item; /* a filtered list's row shows some item of the list */ + return part->pointer >= 0 && pure_app_row_item(app, part->view, row, &item) && + pure_app_point(app, (uint32_t)part->pointer, item) == PURE_OK; } VegaTone vega_tone(PureApp *app, const VegaPart *part, size_t row) { @@ -45,14 +45,18 @@ unsigned vega_check(const PureModule *, void (*report)(void *, const char *name, * shortest form. false when it has no value (for example a missing item). */ bool vega_text(PureApp *, uint32_t view, size_t row, char out[VEGA_TEXT], size_t *length); bool vega_number(PureApp *, uint32_t view, size_t row, double *out); -size_t vega_rows(const PureApp *, const VegaPart *); -/* The focused row, clamped to the rows; 0 without a focus view. */ +size_t vega_rows(PureApp *, const VegaPart *); +/* The row of the focused item: the focus view names an item (an index's + * position), shown on the row that holds it; VEGA_NO_ROW when a filtered + * list leaves that item out. Clamped to the rows without a filter; 0 + * without a focus view. */ +#define VEGA_NO_ROW SIZE_MAX size_t vega_focus(PureApp *, const VegaPart *); /* Focus a row, as a user pointing at it (a click); false when the part's * focus is not a plain index position or the row is not there. */ bool vega_point(PureApp *, const VegaPart *, size_t row); /* The first row to show of `rows` when `visible` fit: the least scrolling - * from first that keeps the focused row in view. */ + * from first that keeps the focused row in view (any, without one). */ size_t vega_scroll(size_t first, size_t focus, size_t visible, size_t rows); /* The key input answering a key name (Pure's names, as KeyboardEvent.code * spells them), or -1; press sends its press and release. */ @@ -123,8 +123,10 @@ static void screen_json(Buffer *b, PureApp *app, const VegaScreen *screen, size_ puts_(b, comma ? ",{\"part\":" : "{\"part\":"); putf(b, "%u,\"name\":", i); json_name(b, m->view_names[part->view]); - putf(b, ",\"kind\":\"%s\",\"rows\":%zu,\"focus\":%zu,\"first\":%zu,\"pointable\":%s,\"columns\":[", - part->kind == VEGA_LIST ? "list" : "table", rows, rows ? focus : 0, first, part->pointer >= 0 ? "true" : "false"); + putf(b, ",\"kind\":\"%s\",\"rows\":%zu,\"focus\":", part->kind == VEGA_LIST ? "list" : "table", rows); + if (focus < rows) putf(b, "%zu", focus); + else puts_(b, "null"); + putf(b, ",\"first\":%zu,\"pointable\":%s,\"columns\":[", first, part->pointer >= 0 ? "true" : "false"); if (part->kind == VEGA_TABLE) for (unsigned c = 0; c < part->column_count; ++c) { if (c) puts_(b, ","); @@ -224,12 +226,12 @@ static const char *const page_parts[] = { "el('span',{className:'label',textContent:it.label?\?''}),it.detail?el('span',{className:'detail',textContent:it.detail}):null," "it.badge?el('span',{className:'badge',textContent:it.badge}):null);item.setAttribute('role','option');" "item.setAttribute('aria-selected',it.row===p.focus);list.append(item)}" -"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};" +"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" "return el('section',{},head,list)}" "const table=el('table',{},el('thead',{},el('tr',{},...p.columns.map(c=>el('th',{textContent:c})))));" "const body=el('tbody');for(const it of p.items){const tr=el('tr',{className:it.tone,onclick:()=>pick(it.row)}," "...it.cells.map(c=>el('td',{textContent:c?\?'-'})));tr.setAttribute('aria-selected',it.row===p.focus);body.append(tr)}" -"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,p.focus+Math.sign(e.deltaY))))};" +"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" "return el('section',{},head,table)}));" "$('prompt').hidden=!s.prompt;if(s.prompt)$('entry').placeholder=s.prompt;" "const symbols={ArrowUp:'\u2191',ArrowDown:'\u2193',ArrowLeft:'\u2190',ArrowRight:'\u2192',Backspace:'Bksp'," diff --git a/tests/check.py b/tests/check.py index 1e1289f..48487a3 100644 --- a/tests/check.py +++ b/tests/check.py @@ -431,7 +431,7 @@ def web(vega, module, work, seeds, steps=150, visible=4): if model.at >= first + visible: first = model.at - visible + 1 if first + visible > n: first = max(n - visible, 0) assert screen['title'] == 'To do' and screen['fields'] == [{'name': 'left', 'text': model.left()}], screen - assert (listed['rows'], listed['focus'], listed['first']) == (n, model.at, first), (seed, listed, model.at) + assert (listed['rows'], listed['focus'], listed['first']) == (n, model.at if n else None, first), (seed, listed) window = tasks[first:first + visible] assert [(item['row'], item['label'], item['badge'], item['tone']) for item in listed['items']] == \ [(first + i, task['title'], Todo.badge(task), Todo.tone(task)) for i, task in enumerate(window)], seed @@ -489,6 +489,57 @@ def browser(vega, module, work): return len(outcome['steps']) +def split(vega, module, work, seeds, steps=200): + """examples/today.aup: two lists filtered from one list share one focus. + The CLI shows each list's items in order with the focus in whichever + list holds it; point takes a row of either list; the web surface gives + the other list no focus.""" + compared = 0 + for seed in seeds: + rng, tasks, at, script, expected = random.Random(seed + 700), [], 0, [], [] + for _ in range(steps): + roll = rng.random() + opened = [i for i, t in enumerate(tasks) if not t['done']] + closed = [i for i, t in enumerate(tasks) if t['done']] + if roll < 0.3: + word = rng.choice(WORDS) + script.append('text ' + (word.encode().hex() or '-')) + tasks.append({'title': word, 'done': 0}) + at = len(tasks) - 1 + elif roll < 0.5: + name, rows = rng.choice([('open', opened), ('done', closed)]) + row = rng.randrange(len(rows) + 1) + script.append(f'point {name} {row}') + if row < len(rows): at = rows[row] + else: + key = rng.choice(['ArrowUp', 'ArrowDown', 'Space']) + script.append(f'key {key}') + if key == 'ArrowUp': at = max(at - 1, 0) + elif key == 'ArrowDown': at = min(at + 1, max(len(tasks) - 1, 0)) + elif tasks: tasks[at]['done'] = int(not tasks[at]['done']) + if rng.random() < 0.3: + script.append('show') + opened = [i for i, t in enumerate(tasks) if not t['done']] + closed = [i for i, t in enumerate(tasks) if t['done']] + expected += ['Today', f'left: {len(opened)} open', f'open ({len(opened)})'] + expected += [('> ' if i == at else ' ') + pictures(tasks[i]['title']) for i in opened] + expected += [f'done ({len(closed)})'] + expected += [('> ' if i == at else ' ') + pictures(tasks[i]['title']) + ' (muted)' for i in closed] + out = run(vega, 'cli', module, stdin='\n'.join(script) + '\n') + assert out.returncode == 0 and out.stdout.splitlines() == expected, \ + (seed, [(a, b) for a, b in zip(out.stdout.splitlines(), expected) if a != b][:3]) + compared += len(expected) + surface = WebSurface(vega, module) + try: + for word in ('a', 'b', 'c'): surface.call('POST', '/text', word) + surface.call('POST', '/key', 'Space') # c is done: the focus moves to the done list + parts = json.loads(surface.call('GET', '/screen?rows=5')[1])['parts'] + finally: + surface.stop() + assert [(p['name'], p['rows'], p['focus']) for p in parts] == [('open', 2, None), ('done', 1, 0)], parts + return compared + + def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] @@ -680,7 +731,7 @@ def main(): work = Path(directory) env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) modules = {} - for name in ('todo', 'scores'): + for name in ('todo', 'scores', 'today'): 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 @@ -688,6 +739,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['split_list_lines'] = split(vega, modules['today'], work, range(args.seeds)) 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['web_screens'] = web(vega, modules['todo'], work, range(args.seeds)) |
