/** * zcatgui WASM Glue Code * * Provides the JavaScript side of the WASM backend: * - Canvas management * - Event handling (keyboard, mouse) * - Framebuffer presentation */ class ZcatguiRuntime { constructor(canvasId) { this.canvas = document.getElementById(canvasId); if (!this.canvas) { throw new Error(`Canvas with id '${canvasId}' not found`); } this.ctx = this.canvas.getContext('2d'); this.imageData = null; this.wasm = null; this.memory = null; // Event queue this.eventQueue = []; this.maxEvents = 256; // Setup event listeners this.setupEventListeners(); } /** * Load and initialize the WASM module */ async load(wasmPath) { const importObject = { env: { js_canvas_init: (width, height) => this.canvasInit(width, height), js_canvas_present: (pixelsPtr, width, height) => this.canvasPresent(pixelsPtr, width, height), js_get_canvas_width: () => this.canvas.width, js_get_canvas_height: () => this.canvas.height, js_console_log: (ptr, len) => this.consoleLog(ptr, len), js_get_time_ms: () => BigInt(performance.now() | 0), js_poll_event: (bufferPtr) => this.pollEvent(bufferPtr), }, }; const response = await fetch(wasmPath); const bytes = await response.arrayBuffer(); const result = await WebAssembly.instantiate(bytes, importObject); this.wasm = result.instance; this.memory = this.wasm.exports.memory; return this.wasm; } /** * Initialize canvas */ canvasInit(width, height) { this.canvas.width = width; this.canvas.height = height; this.imageData = this.ctx.createImageData(width, height); console.log(`zcatgui: Canvas initialized ${width}x${height}`); } /** * Present framebuffer to canvas */ canvasPresent(pixelsPtr, width, height) { if (!this.imageData || this.imageData.width !== width || this.imageData.height !== height) { this.imageData = this.ctx.createImageData(width, height); } // Copy RGBA pixels from WASM memory to ImageData // Pixels are stored as u32 (RGBA packed), need to read as Uint32Array then convert const pixels32 = new Uint32Array(this.memory.buffer, pixelsPtr, width * height); const pixels8 = this.imageData.data; // Convert from RGBA u32 to separate R, G, B, A bytes for (let i = 0; i < pixels32.length; i++) { const pixel = pixels32[i]; const offset = i * 4; // RGBA format (assuming little-endian) pixels8[offset + 0] = pixel & 0xFF; // R pixels8[offset + 1] = (pixel >> 8) & 0xFF; // G pixels8[offset + 2] = (pixel >> 16) & 0xFF; // B pixels8[offset + 3] = (pixel >> 24) & 0xFF; // A } this.ctx.putImageData(this.imageData, 0, 0); } /** * Log to console from WASM */ consoleLog(ptr, len) { const bytes = new Uint8Array(this.memory.buffer, ptr, len); const text = new TextDecoder().decode(bytes); console.log(`[zcatgui] ${text}`); } /** * Poll event from queue * Returns event type and writes data to buffer */ pollEvent(bufferPtr) { if (this.eventQueue.length === 0) { return 0; // No event } const event = this.eventQueue.shift(); const buffer = new Uint8Array(this.memory.buffer, bufferPtr, 64); switch (event.type) { case 'keydown': buffer[0] = event.keyCode; buffer[1] = this.getModifiers(event); return 1; case 'keyup': buffer[0] = event.keyCode; buffer[1] = this.getModifiers(event); return 2; case 'mousemove': this.writeI32(buffer, 0, event.x); this.writeI32(buffer, 4, event.y); return 3; case 'mousedown': this.writeI32(buffer, 0, event.x); this.writeI32(buffer, 4, event.y); buffer[8] = event.button; return 4; case 'mouseup': this.writeI32(buffer, 0, event.x); this.writeI32(buffer, 4, event.y); buffer[8] = event.button; return 5; case 'wheel': this.writeI32(buffer, 0, event.x); this.writeI32(buffer, 4, event.y); this.writeI32(buffer, 8, event.deltaX); this.writeI32(buffer, 12, event.deltaY); return 6; case 'resize': this.writeU32(buffer, 0, event.width); this.writeU32(buffer, 4, event.height); return 7; case 'quit': return 8; case 'textinput': const encoded = new TextEncoder().encode(event.text); buffer[0] = Math.min(encoded.length, 31); buffer.set(encoded.slice(0, 31), 1); return 9; default: return 0; } } /** * Setup DOM event listeners */ setupEventListeners() { // Keyboard events document.addEventListener('keydown', (e) => { if (this.shouldCaptureKey(e)) { e.preventDefault(); this.queueEvent({ type: 'keydown', keyCode: e.keyCode, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, }); // Also queue text input for printable characters if (e.key.length === 1 && !e.ctrlKey && !e.altKey) { this.queueEvent({ type: 'textinput', text: e.key, }); } } }); document.addEventListener('keyup', (e) => { if (this.shouldCaptureKey(e)) { e.preventDefault(); this.queueEvent({ type: 'keyup', keyCode: e.keyCode, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, }); } }); // Mouse events this.canvas.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); this.queueEvent({ type: 'mousemove', x: Math.floor(e.clientX - rect.left), y: Math.floor(e.clientY - rect.top), }); }); this.canvas.addEventListener('mousedown', (e) => { const rect = this.canvas.getBoundingClientRect(); this.queueEvent({ type: 'mousedown', x: Math.floor(e.clientX - rect.left), y: Math.floor(e.clientY - rect.top), button: e.button, }); }); this.canvas.addEventListener('mouseup', (e) => { const rect = this.canvas.getBoundingClientRect(); this.queueEvent({ type: 'mouseup', x: Math.floor(e.clientX - rect.left), y: Math.floor(e.clientY - rect.top), button: e.button, }); }); this.canvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = this.canvas.getBoundingClientRect(); this.queueEvent({ type: 'wheel', x: Math.floor(e.clientX - rect.left), y: Math.floor(e.clientY - rect.top), deltaX: Math.sign(e.deltaX) * 3, deltaY: Math.sign(e.deltaY) * 3, }); }, { passive: false }); // Resize observer const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target === this.canvas) { this.queueEvent({ type: 'resize', width: entry.contentRect.width, height: entry.contentRect.height, }); } } }); resizeObserver.observe(this.canvas); // Prevent context menu this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // Focus canvas for keyboard input this.canvas.tabIndex = 0; this.canvas.focus(); } /** * Check if we should capture this key event */ shouldCaptureKey(e) { // Always capture when canvas is focused if (document.activeElement === this.canvas) { return true; } // Capture arrow keys, space, etc. even if not focused const captureKeys = [37, 38, 39, 40, 32, 9, 27]; // arrows, space, tab, escape return captureKeys.includes(e.keyCode); } /** * Get modifier flags */ getModifiers(e) { let mods = 0; if (e.ctrlKey) mods |= 1; if (e.shiftKey) mods |= 2; if (e.altKey) mods |= 4; return mods; } /** * Queue an event */ queueEvent(event) { if (this.eventQueue.length < this.maxEvents) { this.eventQueue.push(event); } } /** * Write i32 to buffer (little-endian) */ writeI32(buffer, offset, value) { const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4); view.setInt32(0, value, true); } /** * Write u32 to buffer (little-endian) */ writeU32(buffer, offset, value) { const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4); view.setUint32(0, value, true); } } // Export for use if (typeof module !== 'undefined' && module.exports) { module.exports = { ZcatguiRuntime }; } else { window.ZcatguiRuntime = ZcatguiRuntime; }