diff --git a/example/wasm-vt/index.html b/example/wasm-vt/index.html index d720e2375..2fe30b801 100644 --- a/example/wasm-vt/index.html +++ b/example/wasm-vt/index.html @@ -37,6 +37,26 @@ box-sizing: border-box; resize: vertical; } + .effects-log { + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #444; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + font-family: 'Courier New', monospace; + white-space: pre-wrap; + font-size: 13px; + max-height: 200px; + overflow-y: auto; + } + .effects-log .effect-label { + color: #00d9ff; + font-weight: bold; + } + .effects-log .effect-data { + color: #a0ffa0; + } button { background: #0066cc; color: white; @@ -105,11 +125,14 @@
Use \x1b for ESC, \r\n for CR+LF. Press "Run" to process.
+ +Use \x1b for ESC, \r\n for CR+LF, \x07 for BEL. Press "Run" to process.
+Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.
@@ -118,11 +141,119 @@ let wasmInstance = null; let wasmMemory = null; let typeLayout = null; + let effectsLog = []; + + function logEffect(label, data) { + effectsLog.push({ label, data }); + } + + // Patch a WASM binary to: (1) make the function table growable by + // removing its max limit, and (2) export it as "tbl" so JS can add + // effect callback entries. Zig's WASM linker sets a fixed max on + // the table and does not export it by default. + function patchWasmForEffects(buffer) { + let bytes = new Uint8Array(buffer); + + function readLEB128(arr, offset) { + let result = 0, shift = 0, bytesRead = 0; + while (true) { + const byte = arr[offset + bytesRead]; + result |= (byte & 0x7f) << shift; + bytesRead++; + if ((byte & 0x80) === 0) break; + shift += 7; + } + return { value: result, bytesRead }; + } + + function encodeLEB128(value) { + const out = []; + do { + let b = value & 0x7f; + value >>>= 7; + if (value !== 0) b |= 0x80; + out.push(b); + } while (value !== 0); + return out; + } + + // Rebuild a section from parts: [before_section | id | new_size | new_payload | after_section] + function rebuildSection(buf, sectionPos, sectionStart, oldSectionSize, newPayload) { + const newSize = encodeLEB128(newPayload.length); + const afterStart = sectionStart + oldSectionSize; + const result = new Uint8Array(sectionPos + 1 + newSize.length + newPayload.length + (buf.length - afterStart)); + result.set(buf.subarray(0, sectionPos)); + let w = sectionPos; + result[w++] = buf[sectionPos]; // section id + for (const b of newSize) result[w++] = b; + for (const b of newPayload) result[w++] = b; + result.set(buf.subarray(afterStart), w); + return result; + } + + // Pass 1: Patch table section (id=4) to remove max limit so the table is growable. + let pos = 8; + while (pos < bytes.length) { + const sectionId = bytes[pos]; + const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); + const sectionStart = pos + 1 + sizeLen; + if (sectionId === 4) { + const { value: tableCount, bytesRead: countLen } = readLEB128(bytes, sectionStart); + let tpos = sectionStart + countLen; + const elemType = bytes[tpos++]; // 0x70 = funcref + const flags = bytes[tpos]; + if (flags & 1) { + // Has max — rebuild without it: flag=0, keep only min + const { value: min, bytesRead: minLen } = readLEB128(bytes, tpos + 1); + const { value: max, bytesRead: maxLen } = readLEB128(bytes, tpos + 1 + minLen); + const afterLimits = tpos + 1 + minLen + maxLen; + const newPayload = [ + ...encodeLEB128(tableCount), + elemType, + 0x00, // flags: no max + ...encodeLEB128(min), + ...bytes.slice(afterLimits, sectionStart + sectionSize), + ]; + bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, new Uint8Array(newPayload)); + } + break; + } + pos = sectionStart + sectionSize; + } + + // Pass 2: Add table export to export section (id=7) if not already present. + const mod = new WebAssembly.Module(bytes.buffer); + if (WebAssembly.Module.exports(mod).some(e => e.kind === 'table')) { + return bytes.buffer; + } + const exportEntry = [0x03, 0x74, 0x62, 0x6c, 0x01, 0x00]; // name="tbl", kind=table, index=0 + pos = 8; + while (pos < bytes.length) { + const sectionId = bytes[pos]; + const { value: sectionSize, bytesRead: sizeLen } = readLEB128(bytes, pos + 1); + const sectionStart = pos + 1 + sizeLen; + if (sectionId === 7) { + const { value: count, bytesRead: countLen } = readLEB128(bytes, sectionStart); + const restStart = sectionStart + countLen; + const restLen = sectionSize - countLen; + const newPayload = new Uint8Array([ + ...encodeLEB128(count + 1), + ...bytes.slice(restStart, restStart + restLen), + ...exportEntry, + ]); + bytes = rebuildSection(bytes, pos, sectionStart, sectionSize, newPayload); + break; + } + pos = sectionStart + sectionSize; + } + + return bytes.buffer; + } async function loadWasm() { try { const response = await fetch('../../zig-out/bin/ghostty-vt.wasm'); - const wasmBytes = await response.arrayBuffer(); + const wasmBytes = patchWasmForEffects(await response.arrayBuffer()); const wasmModule = await WebAssembly.instantiate(wasmBytes, { env: { @@ -191,7 +322,119 @@ // GHOSTTY_SUCCESS = 0 const GHOSTTY_SUCCESS = 0; - function run() { + // GhosttyTerminalOption enum values + const GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1; + const GHOSTTY_TERMINAL_OPT_BELL = 2; + const GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5; + + // Allocate slots in the WASM indirect function table for JS callbacks. + // Returns the table index (i.e. the function pointer value in WASM). + let effectTableIndices = []; + function addToWasmTable(func) { + const table = wasmInstance.exports.tbl || wasmInstance.exports.__indirect_function_table; + const idx = table.length; + table.grow(1); + table.set(idx, func); + effectTableIndices.push(idx); + return idx; + } + + // Build a tiny WASM trampoline module that imports JS callbacks and + // re-exports them as properly typed WASM functions. This is needed + // because adding JS functions to a WASM table requires them to be + // wrapped as WebAssembly function objects with the correct signature. + // WebAssembly.Function is not supported in all browsers (e.g. Safari), + // so we compile a minimal module instead. + let effectWrappers = null; + async function buildEffectWrappers() { + if (effectWrappers) return effectWrappers; + + // Hand-coded WASM module with: + // Type 0: (i32, i32, i32, i32) -> void [write_pty] + // Type 1: (i32, i32) -> void [bell, title_changed] + // Imports: env.a (type 0), env.b (type 1), env.c (type 1) + // Functions 3,4,5 wrap imports 0,1,2 respectively + // Exports: "a" -> func 3, "b" -> func 4, "c" -> func 5 + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + + // Type section (id=1) + 0x01, 0x0d, // section id, size=13 + 0x02, // 2 types + // type 0: (i32, i32, i32, i32) -> () + 0x60, 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, + // type 1: (i32, i32) -> () + 0x60, 0x02, 0x7f, 0x7f, 0x00, + + // Import section (id=2) + 0x02, 0x19, // section id, size=25 + 0x03, // 3 imports + // import 0: env.a type 0 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x61, // "a" + 0x00, 0x00, // func, type 0 + // import 1: env.b type 1 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x62, // "b" + 0x00, 0x01, // func, type 1 + // import 2: env.c type 1 + 0x03, 0x65, 0x6e, 0x76, // "env" + 0x01, 0x63, // "c" + 0x00, 0x01, // func, type 1 + + // Function section (id=3) + 0x03, 0x04, // section id, size=4 + 0x03, // 3 functions + 0x00, 0x01, 0x01, // types: 0, 1, 1 + + // Export section (id=7) + 0x07, 0x0d, // section id, size=13 + 0x03, // 3 exports + 0x01, 0x61, 0x00, 0x03, // "a" -> func 3 + 0x01, 0x62, 0x00, 0x04, // "b" -> func 4 + 0x01, 0x63, 0x00, 0x05, // "c" -> func 5 + + // Code section (id=10) + 0x0a, 0x20, // section id, size=32 + 0x03, // 3 function bodies + // func 3: call import 0 with 4 args + 0x0c, 0x00, // body size=12, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, + // func 4: call import 1 with 2 args + 0x08, 0x00, // body size=8, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x10, 0x01, 0x0b, + // func 5: call import 2 with 2 args + 0x08, 0x00, // body size=8, 0 locals + 0x20, 0x00, 0x20, 0x01, 0x10, 0x02, 0x0b, + ]); + + const mod = await WebAssembly.instantiate(bytes, { + env: { + a: (terminal, userdata, dataPtr, len) => { + const b = new Uint8Array(getBuffer(), dataPtr, len); + const text = new TextDecoder().decode(b.slice()); + const hex = Array.from(b).map(v => v.toString(16).padStart(2, '0')).join(' '); + logEffect('write_pty', `${len} bytes: ${hex} "${text}"`); + }, + b: (terminal, userdata) => { + logEffect('bell', 'BEL received!'); + }, + c: (terminal, userdata) => { + logEffect('title_changed', 'Terminal title changed'); + }, + }, + }); + + effectWrappers = { + writePtyWrapper: mod.instance.exports.a, + bellWrapper: mod.instance.exports.b, + titleChangedWrapper: mod.instance.exports.c, + }; + return effectWrappers; + } + + async function run() { const outputDiv = document.getElementById('output'); try { @@ -221,6 +464,17 @@ const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true); wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr); + // Register effect callbacks + effectsLog = []; + const wrappers = await buildEffectWrappers(); + const writePtyIdx = addToWasmTable(wrappers.writePtyWrapper); + const bellIdx = addToWasmTable(wrappers.bellWrapper); + const titleIdx = addToWasmTable(wrappers.titleChangedWrapper); + + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_WRITE_PTY, writePtyIdx); + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_BELL, bellIdx); + wasmInstance.exports.ghostty_terminal_set(termPtr, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, titleIdx); + // Write VT data to the terminal const vtBytes = new TextEncoder().encode(vtText); const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length); @@ -290,6 +544,16 @@ outputDiv.className = 'output'; outputDiv.textContent = output; + // Render effects log + const effectsDiv = document.getElementById('effectsLog'); + if (effectsLog.length === 0) { + effectsDiv.textContent = 'No effects triggered.'; + } else { + effectsDiv.innerHTML = effectsLog.map(e => + `[${e.label}] ${e.data}` + ).join('\n'); + } + // Clean up wasmInstance.exports.ghostty_free(0, outPtr, outLen); wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr); @@ -328,7 +592,7 @@ runBtn.addEventListener('click', run); // Run the default example on load - run(); + await run(); return; } catch (e) { statusDiv.textContent = `Error: ${e.message}`;