/** * Manages embedded Pico-8 cart preview within the application. */ export class EmbeddedPreview { /** * @param {object} options Configuration options * @param {() => string} options.getLuaCode Function to get generated Lua code */ constructor(options) { this.getLuaCode = options.getLuaCode; this.modal = null; this.canvas = null; this.isOpen = false; this.isRunning = false; } /** * Creates the preview modal if it doesn't exist */ #ensureModal() { if (this.modal) { return; } // Create modal structure this.modal = document.createElement('div'); this.modal.className = 'preview-modal'; this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-modal', 'true'); this.modal.setAttribute('aria-labelledby', 'previewModalTitle'); this.modal.hidden = true; const backdrop = document.createElement('div'); backdrop.className = 'preview-modal__backdrop'; backdrop.addEventListener('click', () => this.close()); const panel = document.createElement('div'); panel.className = 'preview-modal__panel'; const header = document.createElement('div'); header.className = 'preview-modal__header'; // Header controls (left side) const headerControls = document.createElement('div'); headerControls.className = 'preview-header-controls'; const refreshButton = document.createElement('button'); refreshButton.type = 'button'; refreshButton.className = 'preview-header-btn'; refreshButton.setAttribute('aria-label', 'Reload cart'); refreshButton.innerHTML = ''; refreshButton.addEventListener('click', () => this.#runCart()); const stopButton = document.createElement('button'); stopButton.type = 'button'; stopButton.className = 'preview-header-btn preview-header-btn--stop'; stopButton.setAttribute('aria-label', 'Stop cart'); stopButton.innerHTML = ''; stopButton.addEventListener('click', () => this.#stopCart()); const saveButton = document.createElement('button'); saveButton.type = 'button'; saveButton.className = 'preview-header-btn preview-header-btn--save'; saveButton.setAttribute('aria-label', 'Save .p8'); saveButton.innerHTML = ''; saveButton.addEventListener('click', () => this.#downloadCart()); headerControls.appendChild(refreshButton); headerControls.appendChild(stopButton); headerControls.appendChild(saveButton); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'preview-modal__close'; closeButton.setAttribute('aria-label', 'Close preview'); closeButton.textContent = '×'; closeButton.addEventListener('click', () => this.close()); header.appendChild(headerControls); header.appendChild(closeButton); const content = document.createElement('div'); content.className = 'preview-modal__content'; // Create iframe for PICO-8 player this.iframe = document.createElement('iframe'); this.iframe.className = 'preview-iframe'; this.iframe.allow = 'gamepad'; this.iframe.style.display = 'none'; // Create the canvas for rendering placeholder this.canvas = document.createElement('canvas'); this.canvas.width = 128; this.canvas.height = 128; this.canvas.className = 'preview-canvas'; content.appendChild(this.iframe); content.appendChild(this.canvas); // Refocus iframe when clicking in the modal to ensure input keeps working panel.addEventListener('click', (e) => { if (this.iframe.style.display !== 'none') { setTimeout(() => { this.iframe.focus(); if (this.iframe.contentWindow) { this.iframe.contentWindow.focus(); } }, 0); } }); panel.appendChild(header); panel.appendChild(content); this.modal.appendChild(backdrop); this.modal.appendChild(panel); document.body.appendChild(this.modal); } /** * Runs the cart in the embedded preview */ async #runCart() { try { const luaCode = this.getLuaCode(); if (!luaCode || !luaCode.trim()) { return; } this.isRunning = true; // Generate cart content const cartContent = this.#generateCartContent(luaCode); // Load cart in iframe using PICO-8 web player await this.#loadCartInPlayer(cartContent); } catch (error) { console.error('Error running cart:', error); } } /** * Loads a cart into the PICO-8 web player * * @param {string} cartData The .p8 cart file content as a string */ async #loadCartInPlayer(cartData) { try { const electronAPI = window.electronAPI; if (!electronAPI?.writePlayerHtml) { throw new Error('Player IPC not available'); } const cartB64 = btoa(unescape(encodeURIComponent(cartData))); // HTML references pico8_edu.js by relative path — both files live in public/. const playerHtml = ` `; const { filePath } = await electronAPI.writePlayerHtml(playerHtml); const fileUrl = 'file:///' + filePath.replace(/\\/g, '/') + '?t=' + Date.now(); this.iframe.src = 'about:blank'; this.iframe.src = fileUrl; this.iframe.style.display = 'block'; this.canvas.style.display = 'none'; // Focus iframe once loaded so it captures keyboard/gamepad input this.iframe.onload = () => { setTimeout(() => { this.iframe.focus(); if (this.iframe.contentWindow) { this.iframe.contentWindow.focus(); } }, 500); }; } catch (error) { console.error('Failed to load player:', error); } } /** * Stops the running cart */ #stopCart() { this.isRunning = false; // Hide iframe and show canvas if (this.iframe) { this.iframe.src = 'about:blank'; this.iframe.style.display = 'none'; } if (this.canvas) { this.canvas.style.display = 'block'; const ctx = this.canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 128, 128); } } } /** * Downloads the current cart as a .p8 file */ #downloadCart() { try { const luaCode = this.getLuaCode(); const cartContent = this.#generateCartContent(luaCode); const blob = new Blob([cartContent], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `picograph_${Date.now()}.p8`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Error downloading cart:', error); } } /** * Generates the .p8 cart file content * * @param {string} luaCode Generated Lua code * @returns {string} */ #generateCartContent(luaCode) { const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode); const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode); const fallback = (hasUpdateCallback && hasDrawCallback) ? '' : (!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') + (!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : ''); return `pico-8 cartridge // http://www.pico-8.com version 41 __lua__ ${luaCode}${fallback} __gfx__ 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 `; } /** * Opens the preview modal */ open() { this.#ensureModal(); this.modal.hidden = false; this.isOpen = true; // Auto-run the cart this.#runCart(); } /** * Closes the preview modal */ close() { this.#stopCart(); if (this.modal) { this.modal.hidden = true; this.isOpen = false; } } /** * Toggles the preview modal */ toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } }