// 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. } } }