const CELL_SIZE = 20; const ATTR_TAGLINE = 1 << 7; const ANIMATION_FRAME_MS = 1000 / 60; const MAX_ANIMATION_TICKS = 4; 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; const taglinePercent = this.wasm.pardes_gui_tagline_font_percent(); if (taglinePercent < 1 || taglinePercent > 100) throw new Error(`bad tagline font percentage ${taglinePercent}`); this.root.style.setProperty("--pardes-tagline-font-size", `${taglinePercent}%`); } 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.cellWidth = 1; this.cellHeight = 1; this.running = false; this.pointer = null; this.frameHandle = 0; this.animationClock = { last: null, lag: 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.resize(); this.wasm.pardes_tick(); this.renderer.render(); this.running = true; this.animationClock = { last: null, lag: 0 }; this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp)); this.root.focus({ preventScroll: true }); } stop() { this.running = false; if (this.frameHandle) cancelAnimationFrame(this.frameHandle); this.wasm.pardes_deinit(); } 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_animation_tick(); } this.wasm.pardes_tick(); if (this.wasm.pardes_should_quit()) this.running = false; this.renderer.render(); this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp)); } 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) { return putInput(this.wasm, text); } 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("pointerleave", () => this.wasm.pardes_pointer_leave()); this.root.addEventListener("wheel", (event) => this.wheel(event), { passive: false }); globalThis.addEventListener?.("resize", () => this.resize()); } } /// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A /// high-refresh display therefore renders repeated states rather than making /// frame-count transitions run twice as fast. Long suspension is capped so a /// returning tab cannot execute an unbounded loop before drawing. export function animationTicks(clock, timestamp) { if (!Number.isFinite(timestamp)) return 0; if (clock.last === null || timestamp < clock.last) { clock.last = timestamp; clock.lag = 0; return 0; } const elapsed = Math.min(timestamp - clock.last, ANIMATION_FRAME_MS * MAX_ANIMATION_TICKS); clock.last = timestamp; clock.lag += elapsed; const ticks = Math.min(MAX_ANIMATION_TICKS, Math.floor((clock.lag + 1e-7) / ANIMATION_FRAME_MS)); clock.lag -= ticks * ANIMATION_FRAME_MS; 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 { 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; } } 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") { 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))); cell.classList.toggle("is-tagline", Boolean(attrs & ATTR_TAGLINE)); } 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 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 })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 0); }