panels / src /render.js
pontus-espe's picture
Upload folder using huggingface_hub
3c4a065 verified
Raw
History Blame Contribute Delete
3.84 kB
// 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.
}
}
}