summaryrefslogtreecommitdiff
path: root/src/web
diff options
context:
space:
mode:
Diffstat (limited to 'src/web')
-rw-r--r--src/web/app.mjs106
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 }));