diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-01 22:44:19 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | d59397d4146ffa23adbbe6dc99c64ebb22a93b2d (patch) | |
| tree | a06984bf172a6c894c1586d58aad02ec302b6c2d /src/web/app.mjs | |
| parent | cbcdf01764e2e144a821643c3a8e9a5ad39eda2e (diff) | |
| download | pardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.tar.gz pardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.zip | |
add vanilla DOM web backend
Diffstat (limited to 'src/web/app.mjs')
| -rw-r--r-- | src/web/app.mjs | 436 |
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); +} |
