const CELL_SIZE = 20; const ATTR_TAGLINE = 1 << 7; 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, back: 8, forward: 9 }); 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; this.gripFonts = new Map(); this.gripMetrics = new Map(); this.root.ownerDocument.fonts?.addEventListener("loadingdone", () => this.resetGripMetrics()); 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(); this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text"; 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); const tagCovered = new Uint8Array(count); const tagPrefixBands = new Map(); const paneIndicators = new Set(); if (this.wasm.pardes_tag_layer_limit) { for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { const val = field => this.wasm.pardes_tag_layer_value(i, field); if (!val(0)) continue; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const textInset = this.wasm.pardes_tag_text_inset?.() ?? gripCols; // A row to each line of a taller tag. for (let y = val(5); y < val(5) + Math.max(1, val(11)); y++) { if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { const offset = this.wasm.pardes_tagline_band_offset(y,rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(y*cols+x,offset); if (val(1) === 2) paneIndicators.add(y*cols+val(4)-textInset); } for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { if (y < rows) tagCovered[y * cols + x] = 1; } } } } 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]; cell.style.visibility = tagCovered[index] ? "hidden" : ""; const gripOffset = tagPrefixBands.get(index); cell.style.height = gripOffset === undefined ? "" : `${this.taglineHeight}px`; cell.style.lineHeight = gripOffset === undefined ? "" : `${this.taglineHeight}px`; cell.style.marginTop = gripOffset === undefined ? "" : `${gripOffset}px`; const displayedText = paneIndicators.has(index) ? " " : text; if (cell.textContent !== displayedText) cell.textContent = displayedText; 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.renderBodyLayers(); this.renderTagLayers(raw); this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar }; return this.snapshot; } renderTagLayers(canonical) { if (!this.wasm.pardes_tag_layer_limit || !this.cellWidth) return; if (!this.tagRoot?.isConnected) { this.tagRoot = this.root.ownerDocument.createElement("div"); this.tagRoot.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden"; this.root.style.position = "relative"; this.root.append(this.tagRoot); } this.tagNodes ??= new Map(); const nodes = []; const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight; for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { const layerValues = Array.from({length:13}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); const cols = layerValues[0]; if (!cols) continue; const ptr = this.wasm.pardes_tag_layer_cells(i); if (!ptr) continue; // A row to each line of a taller tag, each drawn as a band of its own. for (let line = 0; line < Math.max(1, layerValues[11]); line++) { const key = i*64+line; const values = layerValues.slice(0,11); values[5] = layerValues[5] + line; values[7] = 1; values[8] = layerValues[12] === line ? layerValues[8] : 65535; const bytes = new Uint8Array(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols); const gripCell = canonical[values[5]*this.cols+gripX]; const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1; const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":"); const cached = this.tagNodes.get(key); if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) { nodes.push(cached.node); if (cached.grip) nodes.push(cached.grip); continue; } const view = new DataView(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const layer = this.root.ownerDocument.createElement("div"); layer.dataset.tagLayer = String(i); layer.dataset.tagLine = String(line); layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`; for (let col = 0; col < cols; col++) { const off = col*CELL_SIZE; const cell = this.root.ownerDocument.createElement("span"); cell.className = "pardes-cell"; cell.textContent = decoder.decode(bytes.subarray(off,off+view.getUint8(off+18))) || " "; applyStyle(cell,view.getUint32(off+8,true),view.getUint32(off+12,true),view.getUint16(off+16,true),col===values[8],values[9]!==0); cell.style.textAlign = "left"; cell.style.width = `${tw}px`; cell.style.height = `${th}px`; cell.style.lineHeight = `${th}px`; layer.append(cell); } let gripNode = null; if (grip && gripCell.text.trim()) { const cell = this.cells[values[5]*this.cols+gripX]; gripNode = this.drawGripGlyph(cell, gripCell, gripCols*bw, th); gripNode.dataset.tagGrip = String(i); gripNode.style.left = `${gripX*bw}px`; gripNode.style.top = `${values[5]*bh+offset}px`; } else if (grip && th >= 6 && tw >= 3) { gripNode = this.root.ownerDocument.createElement("div"); gripNode.dataset.tagGrip = String(i); const centerX = (gripX+gripCols/2)*bw, centerY = values[5]*bh+offset+th/2; const half = Math.max(1,Math.min(3,bw-2)); gripNode.style.cssText = "position:absolute;inset:0;pointer-events:none"; for (const dy of [-2,1]) { const mark = this.root.ownerDocument.createElement("div"); mark.style.cssText = `position:absolute;left:${Math.floor(centerX-half)}px;top:${Math.floor(centerY+dy)}px;width:${2*half}px;height:1px;background:${color(gripCell.fg,DEFAULT_FG)}`; gripNode.append(mark); } } this.tagNodes.set(key,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); nodes.push(layer); if (gripNode) nodes.push(gripNode); } } if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes); } resetGripMetrics() { this.gripFonts.clear(); this.gripMetrics.clear(); this.tagNodes?.clear(); } drawGripGlyph(cell, glyph, width, height) { const canvas = this.root.ownerDocument.createElement("canvas"); const ratio = globalThis.devicePixelRatio || 1; canvas.width = Math.max(1, Math.ceil(width*ratio)); canvas.height = Math.max(1, Math.ceil(height*ratio)); canvas.style.cssText = `position:absolute;pointer-events:none;width:${width}px;height:${height}px`; canvas.setAttribute("aria-label", glyph.text); const ctx = canvas.getContext("2d"); // Resolve a face once per style, then retain its ink bearings for each // symbol. Font loading and runtime metric changes clear both caches. let font = this.gripFonts.get(glyph.attrs); if (!font) { const style = this.root.ownerDocument.defaultView.getComputedStyle(cell); font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; this.gripFonts.set(glyph.attrs, font); } ctx.scale(ratio, ratio); ctx.font = font; const key = `${font}:${ratio}:${glyph.text}`; let ink = this.gripMetrics.get(key); if (!ink) { const measured = ctx.measureText(glyph.text); ink = { left:measured.actualBoundingBoxLeft, right:measured.actualBoundingBoxRight, ascent:measured.actualBoundingBoxAscent, descent:measured.actualBoundingBoxDescent }; this.gripMetrics.set(key, ink); } ctx.fillStyle = cell.style.color; ctx.fillText(glyph.text, (width-ink.right+ink.left)/2, (height+ink.ascent-ink.descent)/2); return canvas; } renderBodyLayers() { if (!this.wasm.pardes_body_layer_limit || !this.cellWidth) return; if (!this.layerRoot?.isConnected) { this.layerRoot = this.root.ownerDocument.createElement("div"); this.layerRoot.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden"; this.root.style.position = "relative"; this.root.append(this.layerRoot); } const nodes = []; this.layerNodes ??= new Map(); const bw = this.cellWidth, bh = this.cellHeight; const tw = this.taglineWidth, th = this.taglineHeight; for (let i = 0; i < this.wasm.pardes_body_layer_limit(); i++) { const val = (field) => this.wasm.pardes_body_layer_value(i, field); const rows = val(0), cols = val(1), contexts = val(2); if (!rows) continue; const ptr = this.wasm.pardes_body_layer_cells(i); if (!ptr) continue; const view = new DataView(this.wasm.memory.buffer, ptr, rows * cols * CELL_SIZE); const bytes = new Uint8Array(this.wasm.memory.buffer); const geometry = [bw,bh,tw,th,...Array.from({length:11+contexts},(_,field)=>val(field))].join(":"); const encoded = bytes.subarray(ptr,ptr+rows*cols*CELL_SIZE); const cached = this.layerNodes.get(i); if (cached && cached.geometry===geometry && cached.bytes.length===encoded.length && cached.bytes.every((byte,index)=>byte===encoded[index])) { nodes.push(cached.node); continue; } const layer = this.root.ownerDocument.createElement("div"); layer.style.cssText = `position:absolute;overflow:hidden;left:${val(3)*bw}px;top:${val(4)*bh}px;width:${val(5)*bw}px;height:${val(6)*bh}px;background:${DEFAULT_BG}`; for (let row = 0; row < rows; row++) { const compact = row < contexts; const height = compact ? th : bh, width = compact ? tw : bw; const top = Math.min(row, contexts)*th + Math.max(0,row-contexts)*bh; if (top >= val(6)*bh) break; const line = this.root.ownerDocument.createElement("div"); line.style.cssText = `position:absolute;left:0;right:0;top:${top}px;height:${height}px;line-height:${height}px;white-space:pre;overflow:hidden`; line.style.backgroundColor = color(view.getUint32(row*cols*CELL_SIZE+12,true),DEFAULT_BG); for (let col = 0; col < cols; col++) { const off = (row*cols+col)*CELL_SIZE; const cell = this.root.ownerDocument.createElement("span"); cell.className = "pardes-cell"; cell.textContent = decoder.decode(bytes.subarray(ptr+off,ptr+off+view.getUint8(off+18))) || " "; applyStyle(cell, view.getUint32(off+8,true), view.getUint32(off+12,true), view.getUint16(off+16,true), col===val(8)&&row===val(9), val(10)!==0); cell.style.textAlign = "left"; cell.style.width = `${width}px`; cell.style.height = `${height}px`; cell.style.lineHeight = `${height}px`; line.append(cell); } layer.append(line); } for (let row = 0; row < contexts; row++) { if (!val(11 + row)) continue; const border = this.root.ownerDocument.createElement("div"); border.dataset.contextBorder = String(row); border.style.cssText = `position:absolute;left:0;right:0;top:${(row+1)*th}px;height:1px;background:currentColor;opacity:.45`; layer.append(border); } this.layerNodes.set(i,{geometry,bytes:encoded.slice(),node:layer}); nodes.push(layer); } if (nodes.length!==this.layerRoot.childNodes.length || nodes.some((node,index)=>node!==this.layerRoot.childNodes[index])) this.layerRoot.replaceChildren(...nodes); } 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.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.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; if (this.wasm.pardes_gui_tagline_font_percent() !== this.measuredTaglinePercent) { this.resize(); this.root.style.setProperty("--pardes-tagline-font-size", `${this.measuredTaglinePercent}%`); } // The core's clock is this timestamp: it steps its own animations // by it, however fast the display refreshes. this.wasm.pardes_tick(timestamp); 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); const ratio = this.wasm.pardes_gui_tagline_font_percent() / 100; this.measuredTaglinePercent = this.wasm.pardes_gui_tagline_font_percent(); this.taglineWidth = this.cellWidth * ratio; this.taglineHeight = this.cellHeight * ratio; Object.assign(this.renderer, { cellWidth:this.cellWidth, cellHeight:this.cellHeight, taglineWidth:this.taglineWidth, taglineHeight:this.taglineHeight }); const style = this.root.ownerDocument.defaultView.getComputedStyle(this.root); const fontKey = [style.fontFamily,style.fontSize,style.fontWeight,style.fontStyle,ratio,globalThis.devicePixelRatio].join(":"); if (fontKey !== this.gripFontKey) { this.renderer.resetGripMetrics(); this.gripFontKey = fontKey; } } 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); this.wasm.pardes_row_metrics?.(Math.max(1,Math.round(this.cellWidth)),Math.max(1,Math.round(this.cellHeight)),Math.max(1,Math.round(this.taglineWidth)),Math.max(1,Math.round(this.taglineHeight))); } 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(); this.sampledPointer = { x:event.clientX-rect.left, y:event.clientY-rect.top }; 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 }; } mouse(button, kind, col, row, mods) { if (this.wasm.pardes_mouse_pixel && this.sampledPointer) { this.wasm.pardes_mouse_pixel(button,kind,col,row,mods,this.sampledPointer.x*256,this.sampledPointer.y*256,Math.round(this.cellWidth*256),Math.round(this.cellHeight*256),Math.round(this.taglineWidth*256),Math.round(this.taglineHeight*256)); } else this.wasm.pardes_mouse(button,kind,col,row,mods); } mouseDown(event) { event.preventDefault(); if (event.button !== 3 && event.button !== 4) return; this.root.focus({ preventScroll: true }); const point = this.point(event); this.mouse(pointerButton(event.button), MOUSE.press, point.col, point.row, modifiers(event)); } pointerDown(event) { // Mouse defaults are cancelled by mousedown. Cancelling pointerdown // would suppress that stream, including thumb presses while left is held. if (event.button === 3 || event.button === 4) return; if (event.pointerType === "touch") event.preventDefault(); this.root.focus({ preventScroll: true }); const point = this.point(event); this.root.setPointerCapture?.(event.pointerId); 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.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.mouse(button, MOUSE.press, point.col, point.row, modifiers(event)); } pointerMove(event) { if (event.button === 3 || event.button === 4) return; const point = this.point(event); const active = this.pointer?.id === event.pointerId ? this.pointer : null; if (!active) { if (event.pointerType !== "touch") this.mouse(BUTTON.none, MOUSE.motion, point.col, point.row, modifiers(event)); return; } event.preventDefault(); if (!active.touch) { this.mouse(active.button, MOUSE.drag, point.col, point.row, modifiers(event)); return; } if (active.chrome) { this.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.mouse(BUTTON.wheelDown, MOUSE.press, point.col, point.row, 0); active.accum += 1; } while (active.accum >= 1) { this.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) { if (event.button === 3 || event.button === 4) { event.preventDefault(); return; } 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.mouse(BUTTON.left, MOUSE.release, point.col, point.row, modifiers(event)); else if (!active.scrolling) { this.mouse(BUTTON.right, MOUSE.press, point.col, point.row, 0); this.mouse(BUTTON.right, MOUSE.release, point.col, point.row, 0); } } else { this.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.mouse(event.deltaY < 0 ? BUTTON.wheelUp : BUTTON.wheelDown, MOUSE.press, point.col, point.row, modifiers(event)); if (event.deltaX) this.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("auxclick", (event) => event.preventDefault()); // Mouse events retain additional button presses while another button is held. this.root.addEventListener("mousedown", (event) => this.mouseDown(event)); 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()); } } 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 === 3 ? BUTTON.back : button === 4 ? BUTTON.forward : 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); }