Spaces:
Running
Running
| // The host renderer. It knows nine node kinds and nothing else: no bindings, | |
| // no state, no effect semantics. Everything it draws was resolved by the WASM | |
| // runtime, and every interaction goes straight back to it. | |
| /** | |
| * @param {HTMLElement} mount | |
| * @param {object} runtime the WASM Runtime | |
| * @param {() => void} onChange called after any state-mutating interaction | |
| */ | |
| export function paint(mount, runtime, onChange) { | |
| const focus = captureFocus(mount); | |
| mount.replaceChildren(); | |
| let tree; | |
| try { | |
| tree = JSON.parse(runtime.render()); | |
| } catch (err) { | |
| mount.append(note(`render failed: ${err.message}`)); | |
| return; | |
| } | |
| if (tree.length === 0) { | |
| mount.append(note('empty view')); | |
| } | |
| for (const node of tree) { | |
| mount.append(build(node, runtime, onChange)); | |
| } | |
| restoreFocus(mount, focus); | |
| } | |
| function build(node, runtime, onChange) { | |
| switch (node.kind) { | |
| case 'col': | |
| case 'row': | |
| case 'list': | |
| case 'if': | |
| return container(node, runtime, onChange); | |
| case 'sp': | |
| return el('div', 'p-sp'); | |
| case 'txt': { | |
| const p = el('p', 'p-txt'); | |
| p.textContent = node.text ?? ''; | |
| return p; | |
| } | |
| case 'btn': { | |
| const b = el('button', 'p-btn'); | |
| b.textContent = node.text ?? ''; | |
| b.title = node.effect ?? ''; | |
| b.addEventListener('click', () => { | |
| // A failed effect is a runtime type error, not a crash: report and | |
| // leave the state alone. | |
| try { | |
| runtime.dispatch(node.effect); | |
| } catch (err) { | |
| console.warn('effect refused:', node.effect, err); | |
| } | |
| onChange(); | |
| }); | |
| return b; | |
| } | |
| case 'in': { | |
| const input = el('input', 'p-in'); | |
| input.type = 'text'; | |
| input.value = node.value ?? ''; | |
| input.dataset.field = node.field; | |
| if (node.placeholder) input.placeholder = node.placeholder; | |
| input.addEventListener('input', () => { | |
| runtime.set_text(node.field, input.value); | |
| onChange(); | |
| }); | |
| return input; | |
| } | |
| case 'chk': { | |
| const label = el('label', 'p-chk'); | |
| const box = el('input'); | |
| box.type = 'checkbox'; | |
| box.checked = Boolean(node.checked); | |
| box.dataset.field = node.field; | |
| box.addEventListener('change', () => { | |
| runtime.set_bool(node.field, box.checked); | |
| onChange(); | |
| }); | |
| label.append(box, document.createTextNode(node.text ?? '')); | |
| return label; | |
| } | |
| default: | |
| return note(`unknown kind: ${node.kind}`); | |
| } | |
| } | |
| function container(node, runtime, onChange) { | |
| const box = el('div', `p-${node.kind}`); | |
| for (const child of node.children ?? []) { | |
| box.append(build(child, runtime, onChange)); | |
| } | |
| return box; | |
| } | |
| function el(tag, className) { | |
| const node = document.createElement(tag); | |
| if (className) node.className = className; | |
| return node; | |
| } | |
| function note(text) { | |
| const p = el('p', 'p-empty'); | |
| p.textContent = text; | |
| return p; | |
| } | |
| // Repainting the whole tree on every keystroke is fine at this size, but it | |
| // would throw away the caret. Put it back where it was. | |
| function captureFocus(mount) { | |
| const active = document.activeElement; | |
| if (!active || !mount.contains(active) || !active.dataset?.field) return null; | |
| return { | |
| field: active.dataset.field, | |
| start: active.selectionStart, | |
| end: active.selectionEnd, | |
| }; | |
| } | |
| function restoreFocus(mount, focus) { | |
| if (!focus) return; | |
| // Field names are `[a-z][a-z0-9_]*` by grammar, so they need no escaping. | |
| const next = mount.querySelector(`[data-field="${focus.field}"]`); | |
| if (!next) return; | |
| next.focus(); | |
| if (typeof next.setSelectionRange === 'function' && focus.start !== null) { | |
| try { | |
| next.setSelectionRange(focus.start, focus.end); | |
| } catch { | |
| // Checkboxes have no selection range; nothing to restore. | |
| } | |
| } | |
| } | |