diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-24 10:56:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 09:42:07 -0300 |
| commit | c3d0b84b7961ae26d2d654e7120821cc2d83d20d (patch) | |
| tree | 0b9a060a8ff0ee7d83c8df5ca797a99e0d66c53e /src/web/app.mjs | |
| parent | 70bde600793ea70bd68832018a154671c6bf1512 (diff) | |
| download | pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.tar.gz pardes-c3d0b84b7961ae26d2d654e7120821cc2d83d20d.zip | |
host: the core owns the event loop; every platform becomes a vtable of optional methods
Diffstat (limited to 'src/web/app.mjs')
| -rw-r--r-- | src/web/app.mjs | 106 |
1 files changed, 56 insertions, 50 deletions
diff --git a/src/web/app.mjs b/src/web/app.mjs index 631c6067..fcb9c23d 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -116,8 +116,6 @@ export class PardesRuntime { this.root = root; this.wasm = wasm; this.renderer = new DomRenderer(root, wasm); - this.inputPtr = wasm.pardes_input_ptr(); - this.inputCapacity = wasm.pardes_input_capacity(); this.cellWidth = 1; this.cellHeight = 1; this.running = false; @@ -132,10 +130,9 @@ export class PardesRuntime { const { cols, rows } = this.gridSize(); const error = this.wasm.pardes_init(cols, rows); if (error) throw new Error(`Pardes wasm init failed (${error})`); - this.inputPtr = this.wasm.pardes_input_ptr(); this.resize(); + this.wasm.pardes_tick(); this.renderer.render(); - this.drainEffects(); this.running = true; this.animationClock = { last: null, lag: 0 }; this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp)); @@ -150,11 +147,15 @@ export class PardesRuntime { frame(timestamp) { if (!this.running) return; + // JS owns the animation clock; the pump that follows applies those ticks, + // runs the core's effects and presents one frame however fast the display + // is. Both halves are needed: an idle core still has input and effects. for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) { - this.wasm.pardes_tick(); + this.wasm.pardes_animation_tick(); } + this.wasm.pardes_tick(); + if (this.wasm.pardes_should_quit()) this.running = false; this.renderer.render(); - this.drainEffects(); this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp)); } @@ -183,10 +184,7 @@ export class PardesRuntime { } writeInput(text) { - const data = encoder.encode(text); - if (data.length > this.inputCapacity) throw new Error("browser input exceeds wasm transfer buffer"); - new Uint8Array(this.wasm.memory.buffer, this.inputPtr, data.length).set(data); - return data.length; + return putInput(this.wasm, text); } key(event) { @@ -313,44 +311,6 @@ export class PardesRuntime { this.root.addEventListener("wheel", (event) => this.wheel(event), { passive: false }); globalThis.addEventListener?.("resize", () => this.resize()); } - - drainEffects() { - for (;;) { - const kind = this.wasm.pardes_effect_next(); - if (!kind) return; - const ptr = this.wasm.pardes_effect_ptr(); - const len = this.wasm.pardes_effect_len(); - const data = new Uint8Array(len); - if (len) data.set(new Uint8Array(this.wasm.memory.buffer, ptr, len)); - const aux0 = this.wasm.pardes_effect_aux0(); - const aux1 = this.wasm.pardes_effect_aux1(); - this.handleEffect(kind, data, aux0, aux1); - } - } - - handleEffect(kind, data, aux0, aux1) { - if (kind === 4) { - globalThis.open?.(decoder.decode(data), "_blank", "noopener,noreferrer"); - } else if (kind === 5) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const pathLength = data.length >= 4 ? view.getUint32(0, true) : 0; - const path = decoder.decode(data.subarray(4, 4 + pathLength)); - download(data.subarray(4 + pathLength), path.split("/").pop() || "pardes.txt", "text/plain;charset=utf-8"); - } else if (kind === 6) { - download(data, "pardes-dump.zon", "text/plain;charset=utf-8"); - } else if (kind === 7) { - navigator.clipboard?.writeText(decoder.decode(data)).catch(() => {}); - } else if (kind === 13) { - // Async and permission-gated, unlike every other effect here: a refused - // or unsupported read is simply a paste that never happens, which is - // what the core already tolerates from an empty clipboard. - navigator.clipboard?.readText().then((text) => this.wasm.pardes_paste(this.writeInput(text))).catch(() => {}); - } else if (kind === 10) { - this.running = false; - } else { - this.root.dispatchEvent(new CustomEvent("pardes-io", { detail: { kind, data, aux0, aux1 } })); - } - } } /// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A @@ -372,17 +332,56 @@ export function animationTicks(clock, timestamp) { return ticks; } +/// The four things the core asks the page for, as the module's imports. The +/// holder exists because the import object has to be built before the instance +/// whose memory those imports read. +export function browserHost() { + const host = { wasm: null }; + const view = (ptr, len) => new Uint8Array(host.wasm.memory.buffer, ptr, len); + const text = (ptr, len) => decoder.decode(view(ptr, len)); + host.imports = { + pardes: { + host_open_link: (ptr, len) => { + globalThis.open?.(text(ptr, len), "_blank", "noopener,noreferrer"); + }, + host_set_clipboard: (ptr, len) => { + globalThis.navigator?.clipboard?.writeText(text(ptr, len)).catch(() => {}); + }, + // Async and permission-gated, unlike every other import here: a refused + // or unsupported read is simply a paste that never happens, which is + // what the core already tolerates from an empty clipboard. + host_read_clipboard: () => { + globalThis.navigator?.clipboard?.readText() + .then((clip) => host.wasm.pardes_paste(putInput(host.wasm, clip))) + .catch(() => {}); + }, + // A file a page writes is a download; the core already resolved the path + // and the bytes, and the Blob needs its own copy of them. + host_download: (pathPtr, pathLen, ptr, len) => { + const name = text(pathPtr, pathLen).split("/").pop() || "pardes.txt"; + download(view(ptr, len).slice(), name, "text/plain;charset=utf-8"); + }, + }, + }; + return host; +} + export async function instantiate(url = "./pardes.wasm") { + const host = browserHost(); const response = await fetch(url); if (!response.ok) throw new Error(`fetch ${url}: HTTP ${response.status}`); if (WebAssembly.instantiateStreaming) { try { - return (await WebAssembly.instantiateStreaming(response.clone(), {})).instance.exports; + const { instance } = await WebAssembly.instantiateStreaming(response.clone(), host.imports); + host.wasm = instance.exports; + return host.wasm; } catch (error) { if (!/mime|content-type/i.test(String(error))) throw error; } } - return (await WebAssembly.instantiate(await response.arrayBuffer(), {})).instance.exports; + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), host.imports); + host.wasm = instance.exports; + return host.wasm; } export async function boot(root, url = "./pardes.wasm") { @@ -461,6 +460,13 @@ function clamp(value, low, high) { return Math.max(low, Math.min(high, value)); } +function putInput(wasm, text) { + const data = encoder.encode(text); + if (data.length > wasm.pardes_input_capacity()) throw new Error("browser input exceeds wasm transfer buffer"); + new Uint8Array(wasm.memory.buffer, wasm.pardes_input_ptr(), data.length).set(data); + return data.length; +} + function download(bytes, name, type) { if (typeof document === "undefined") return; const url = URL.createObjectURL(new Blob([bytes], { type })); |
