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 === 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 } })); } } } 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); }