summaryrefslogtreecommitdiff
path: root/src/web/app.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'src/web/app.mjs')
-rw-r--r--src/web/app.mjs436
1 files changed, 436 insertions, 0 deletions
diff --git a/src/web/app.mjs b/src/web/app.mjs
new file mode 100644
index 00000000..3d32a16b
--- /dev/null
+++ b/src/web/app.mjs
@@ -0,0 +1,436 @@
+const CELL_SIZE = 20;
+const DEFAULT_FG = "#cccccc";
+const DEFAULT_BG = "#121212";
+const decoder = new TextDecoder();
+const encoder = new TextEncoder();
+
+const BUTTON = Object.freeze({ left: 0, middle: 1, right: 2, wheelUp: 3, wheelDown: 4, wheelLeft: 5, wheelRight: 6, none: 7 });
+const MOUSE = Object.freeze({ press: 0, release: 1, motion: 2, drag: 3 });
+const SPECIAL_KEYS = Object.freeze({
+ Enter: 0x0d,
+ Backspace: 0x7f,
+ Tab: 0x09,
+ Escape: 0x1b,
+ ArrowUp: 0xf0001,
+ ArrowDown: 0xf0002,
+ ArrowLeft: 0xf0003,
+ ArrowRight: 0xf0004,
+ Home: 0xf0005,
+ End: 0xf0006,
+ PageUp: 0xf0007,
+ PageDown: 0xf0008,
+ Delete: 0xf0009,
+});
+
+export class DomRenderer {
+ constructor(root, wasm) {
+ this.root = root;
+ this.wasm = wasm;
+ this.rows = [];
+ this.cells = [];
+ this.cols = 0;
+ this.rowCount = 0;
+ this.snapshot = null;
+ }
+
+ render() {
+ const count = this.wasm.pardes_frame();
+ const cols = this.wasm.pardes_frame_cols();
+ const rows = this.wasm.pardes_frame_rows();
+ if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`);
+ this.ensureGrid(cols, rows);
+
+ const ptr = this.wasm.pardes_frame_ptr();
+ const view = new DataView(this.wasm.memory.buffer, ptr, count * CELL_SIZE);
+ const bytes = new Uint8Array(this.wasm.memory.buffer);
+ const cursorX = this.wasm.pardes_cursor_x();
+ const cursorY = this.wasm.pardes_cursor_y();
+ const cursorBar = this.wasm.pardes_cursor_bar() !== 0;
+ const raw = new Array(count);
+
+ for (let index = 0; index < count; index += 1) {
+ const off = index * CELL_SIZE;
+ const len = view.getUint8(off + 18);
+ const fg = view.getUint32(off + 8, true);
+ const bg = view.getUint32(off + 12, true);
+ const attrs = view.getUint16(off + 16, true);
+ const text = decoder.decode(bytes.subarray(ptr + off, ptr + off + len)) || " ";
+ const x = index % cols;
+ const y = Math.floor(index / cols);
+ const cursor = x === cursorX && y === cursorY;
+ const cell = this.cells[index];
+ if (cell.textContent !== text) cell.textContent = text;
+ const styleKey = `${fg}:${bg}:${attrs}:${cursor ? 1 : 0}:${cursorBar ? 1 : 0}`;
+ if (cell.pardesStyle !== styleKey) {
+ applyStyle(cell, fg, bg, attrs, cursor, cursorBar);
+ cell.pardesStyle = styleKey;
+ }
+ raw[index] = { text, fg, bg, attrs };
+ }
+ this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar };
+ return this.snapshot;
+ }
+
+ ensureGrid(cols, rows) {
+ if (cols === this.cols && rows === this.rowCount) return;
+ const document = this.root.ownerDocument;
+ const rowNodes = [];
+ const cells = [];
+ for (let y = 0; y < rows; y += 1) {
+ const row = document.createElement("div");
+ row.className = "pardes-row";
+ row.setAttribute("role", "row");
+ row.dataset.row = String(y);
+ const rowCells = [];
+ for (let x = 0; x < cols; x += 1) {
+ const cell = document.createElement("span");
+ cell.className = "pardes-cell";
+ cell.setAttribute("role", "gridcell");
+ cell.dataset.col = String(x);
+ cell.dataset.row = String(y);
+ cell.textContent = " ";
+ rowCells.push(cell);
+ cells.push(cell);
+ }
+ row.replaceChildren(...rowCells);
+ rowNodes.push(row);
+ }
+ this.root.replaceChildren(...rowNodes);
+ this.root.setAttribute("aria-colcount", String(cols));
+ this.root.setAttribute("aria-rowcount", String(rows));
+ this.rows = rowNodes;
+ this.cells = cells;
+ this.cols = cols;
+ this.rowCount = rows;
+ }
+}
+
+export class PardesRuntime {
+ constructor(root, wasm) {
+ 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;
+ this.pointer = null;
+ this.frameHandle = 0;
+ this.bindEvents();
+ }
+
+ start() {
+ this.measure();
+ 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.renderer.render();
+ this.drainEffects();
+ this.running = true;
+ this.frameHandle = requestAnimationFrame(() => this.frame());
+ this.root.focus({ preventScroll: true });
+ }
+
+ stop() {
+ this.running = false;
+ if (this.frameHandle) cancelAnimationFrame(this.frameHandle);
+ this.wasm.pardes_deinit();
+ }
+
+ frame() {
+ if (!this.running) return;
+ this.wasm.pardes_tick();
+ this.renderer.render();
+ this.drainEffects();
+ this.frameHandle = requestAnimationFrame(() => this.frame());
+ }
+
+ measure() {
+ const probe = this.root.ownerDocument.createElement("span");
+ probe.className = "pardes-probe";
+ probe.textContent = "M";
+ this.root.ownerDocument.body.append(probe);
+ const rect = probe.getBoundingClientRect();
+ probe.remove();
+ this.cellWidth = Math.max(1, rect.width);
+ this.cellHeight = Math.max(1, rect.height);
+ }
+
+ gridSize() {
+ return {
+ cols: Math.max(1, Math.floor(this.root.clientWidth / this.cellWidth)),
+ rows: Math.max(1, Math.floor(this.root.clientHeight / this.cellHeight)),
+ };
+ }
+
+ resize() {
+ this.measure();
+ const { cols, rows } = this.gridSize();
+ this.wasm.pardes_resize(cols, rows);
+ }
+
+ 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;
+ }
+
+ key(event) {
+ const mods = modifiers(event);
+ let cp = SPECIAL_KEYS[event.key];
+ let text = "";
+ if (cp === undefined && event.key.length > 0 && [...event.key].length === 1) {
+ text = mods & 3 ? "" : event.key;
+ const shortcut = event.code?.match(/^Key([A-Z])$/);
+ cp = shortcut && (mods & 3) ? shortcut[1].toLowerCase().codePointAt(0) : event.key.codePointAt(0);
+ }
+ if (cp === undefined) return;
+ event.preventDefault();
+ this.wasm.pardes_key(cp, this.writeInput(text), mods);
+ }
+
+ point(event) {
+ const rect = this.root.getBoundingClientRect();
+ return {
+ col: clamp(Math.floor((event.clientX - rect.left) / this.cellWidth), 0, Math.max(0, this.wasm.pardes_frame_cols() - 1)),
+ row: clamp(Math.floor((event.clientY - rect.top) / this.cellHeight), 0, Math.max(0, this.wasm.pardes_frame_rows() - 1)),
+ };
+ }
+
+ cellPoint(col, row) {
+ const rect = this.root.getBoundingClientRect();
+ return { x: rect.left + (col + 0.5) * this.cellWidth, y: rect.top + (row + 0.5) * this.cellHeight };
+ }
+
+ pointerDown(event) {
+ event.preventDefault();
+ this.root.focus({ preventScroll: true });
+ this.root.setPointerCapture?.(event.pointerId);
+ const point = this.point(event);
+ if (event.pointerType === "touch") {
+ const packed = this.wasm.pardes_chrome_target(point.col, point.row) >>> 0;
+ const chrome = packed !== 0xffffffff;
+ const snapped = chrome ? { col: packed & 0xffff, row: packed >>> 16 } : point;
+ this.pointer = { id: event.pointerId, touch: true, chrome, startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, point: snapped, scrolling: false, accum: 0 };
+ if (chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.press, snapped.col, snapped.row, modifiers(event));
+ return;
+ }
+ const button = pointerButton(event.button);
+ this.pointer = { id: event.pointerId, touch: false, button };
+ this.wasm.pardes_mouse(button, MOUSE.press, point.col, point.row, modifiers(event));
+ }
+
+ pointerMove(event) {
+ const point = this.point(event);
+ const active = this.pointer?.id === event.pointerId ? this.pointer : null;
+ if (!active) {
+ if (event.pointerType !== "touch") this.wasm.pardes_mouse(BUTTON.none, MOUSE.motion, point.col, point.row, modifiers(event));
+ return;
+ }
+ event.preventDefault();
+ if (!active.touch) {
+ this.wasm.pardes_mouse(active.button, MOUSE.drag, point.col, point.row, modifiers(event));
+ return;
+ }
+ if (active.chrome) {
+ this.wasm.pardes_mouse(BUTTON.left, MOUSE.drag, point.col, point.row, modifiers(event));
+ return;
+ }
+ const travel = Math.hypot(event.clientX - active.startX, event.clientY - active.startY);
+ if (travel > 8) active.scrolling = true;
+ if (active.scrolling) {
+ const dy = event.clientY - active.lastY;
+ active.accum += dy / this.cellHeight;
+ while (active.accum <= -1) {
+ this.wasm.pardes_mouse(BUTTON.wheelDown, MOUSE.press, point.col, point.row, 0);
+ active.accum += 1;
+ }
+ while (active.accum >= 1) {
+ this.wasm.pardes_mouse(BUTTON.wheelUp, MOUSE.press, point.col, point.row, 0);
+ active.accum -= 1;
+ }
+ this.wasm.pardes_touch_scroll(dy / this.cellHeight);
+ }
+ active.lastX = event.clientX;
+ active.lastY = event.clientY;
+ }
+
+ pointerUp(event) {
+ const active = this.pointer?.id === event.pointerId ? this.pointer : null;
+ if (!active) return;
+ event.preventDefault();
+ const point = this.point(event);
+ if (active.touch) {
+ if (active.chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.release, point.col, point.row, modifiers(event));
+ else if (!active.scrolling) {
+ this.wasm.pardes_mouse(BUTTON.right, MOUSE.press, point.col, point.row, 0);
+ this.wasm.pardes_mouse(BUTTON.right, MOUSE.release, point.col, point.row, 0);
+ }
+ } else {
+ this.wasm.pardes_mouse(active.button, MOUSE.release, point.col, point.row, modifiers(event));
+ }
+ if (this.root.hasPointerCapture?.(event.pointerId)) this.root.releasePointerCapture(event.pointerId);
+ this.pointer = null;
+ }
+
+ wheel(event) {
+ event.preventDefault();
+ const point = this.point(event);
+ if (event.deltaY) this.wasm.pardes_mouse(event.deltaY < 0 ? BUTTON.wheelUp : BUTTON.wheelDown, MOUSE.press, point.col, point.row, modifiers(event));
+ if (event.deltaX) this.wasm.pardes_mouse(event.deltaX < 0 ? BUTTON.wheelLeft : BUTTON.wheelRight, MOUSE.press, point.col, point.row, modifiers(event));
+ }
+
+ paste(event) {
+ const text = event.clipboardData?.getData("text/plain");
+ if (text === undefined) return;
+ event.preventDefault();
+ this.wasm.pardes_paste(this.writeInput(text));
+ }
+
+ bindEvents() {
+ this.root.addEventListener("keydown", (event) => this.key(event));
+ this.root.addEventListener("paste", (event) => this.paste(event));
+ this.root.addEventListener("contextmenu", (event) => event.preventDefault());
+ this.root.addEventListener("pointerdown", (event) => this.pointerDown(event));
+ this.root.addEventListener("pointermove", (event) => this.pointerMove(event));
+ this.root.addEventListener("pointerup", (event) => this.pointerUp(event));
+ this.root.addEventListener("pointercancel", (event) => this.pointerUp(event));
+ 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 === 10) {
+ this.running = false;
+ } else {
+ this.root.dispatchEvent(new CustomEvent("pardes-io", { detail: { kind, data, aux0, aux1 } }));
+ }
+ }
+}
+
+export async function instantiate(url = "./pardes.wasm") {
+ 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;
+ } catch (error) {
+ if (!/mime|content-type/i.test(String(error))) throw error;
+ }
+ }
+ return (await WebAssembly.instantiate(await response.arrayBuffer(), {})).instance.exports;
+}
+
+export async function boot(root, url = "./pardes.wasm") {
+ if (!root) throw new Error("Pardes DOM root is missing");
+ const wasm = await instantiate(url);
+ const runtime = new PardesRuntime(root, wasm);
+ runtime.start();
+ const test = {
+ grid: () => runtime.renderer.snapshot,
+ cellPoint: (col, row) => runtime.cellPoint(col, row),
+ };
+ globalThis.pardes = { runtime, wasm, test };
+ return runtime;
+}
+
+function applyStyle(cell, fgCode, bgCode, attrs, cursor, cursorBar) {
+ let fg = color(fgCode, DEFAULT_FG);
+ let bg = color(bgCode, DEFAULT_BG);
+ if (attrs & (1 << 4)) [fg, bg] = [bg, fg];
+ if (attrs & (1 << 5)) fg = bg;
+ if (attrs & (1 << 1)) fg = dim(fg);
+ cell.style.color = fg;
+ cell.style.backgroundColor = bg;
+ cell.style.fontWeight = attrs & 1 ? "700" : "400";
+ cell.style.fontStyle = attrs & (1 << 2) ? "italic" : "normal";
+ const underline = (attrs >>> 8) & 7;
+ cell.style.textDecorationLine = `${underline ? "underline " : ""}${attrs & (1 << 6) ? "line-through" : ""}`.trim() || "none";
+ cell.style.textDecorationStyle = ["solid", "solid", "double", "wavy", "dotted", "dashed"][underline] || "solid";
+ cell.style.setProperty("--cell-fg", fg);
+ cell.style.setProperty("--cell-bg", bg);
+ cell.classList.toggle("is-cursor", cursor);
+ cell.classList.toggle("is-bar", cursor && cursorBar);
+ cell.classList.toggle("is-blinking", Boolean(attrs & (1 << 3)));
+}
+
+function color(encoded, fallback) {
+ if (encoded === 0x01000000) return fallback;
+ if ((encoded & 0xff000000) === 0x02000000) return indexedColor(encoded & 0xff);
+ return `#${(encoded & 0xffffff).toString(16).padStart(6, "0")}`;
+}
+
+function indexedColor(index) {
+ const ansi = [
+ "#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
+ "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
+ ];
+ if (index < 16) return ansi[index];
+ if (index >= 232) {
+ const level = 8 + (index - 232) * 10;
+ return rgb(level, level, level);
+ }
+ const n = index - 16;
+ const level = (part) => part === 0 ? 0 : 55 + part * 40;
+ return rgb(level(Math.floor(n / 36)), level(Math.floor(n / 6) % 6), level(n % 6));
+}
+
+function dim(value) {
+ const n = Number.parseInt(value.slice(1), 16);
+ return rgb(((n >>> 16) & 0xff) * 0.6, ((n >>> 8) & 0xff) * 0.6, (n & 0xff) * 0.6);
+}
+
+function rgb(r, g, b) {
+ return `#${[r, g, b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}`;
+}
+
+function modifiers(event) {
+ return (event.ctrlKey ? 1 : 0) | (event.altKey ? 2 : 0) | (event.shiftKey ? 4 : 0);
+}
+
+function pointerButton(button) {
+ return button === 1 ? BUTTON.middle : button === 2 ? BUTTON.right : BUTTON.left;
+}
+
+function clamp(value, low, high) {
+ return Math.max(low, Math.min(high, value));
+}
+
+function download(bytes, name, type) {
+ if (typeof document === "undefined") return;
+ const url = URL.createObjectURL(new Blob([bytes], { type }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}