import type { Value, UIValue } from './types'; import { valueToUI } from './valueToUI'; import { render, hitTest } from './ui'; import { evaluate } from './interpreter'; import { CGError } from './error'; export type App = { init: Value; update: Value; // State / Event / State view: Value; // State / UI } export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { let state = app.init; type ComponentInstance = { state: Value; update: Value; view: Value; }; const componentInstances = new Map(); // Focus tracking let focusedComponentKey: string | null = null; function setFocus(componentKey: string | null) { if (focusedComponentKey === componentKey) return; const oldFocus = focusedComponentKey; focusedComponentKey = componentKey; // Blur event to the previous if (oldFocus && componentInstances.has(oldFocus)) { handleComponentEvent(oldFocus, { kind: 'constructor', name: 'Blurred', args: [] }); } // Focus event to the new if (componentKey && componentInstances.has(componentKey)) { handleComponentEvent(componentKey, { kind: 'constructor', name: 'Focused', args: [] }); } rerender(); } function handleComponentEvent(componentKey: string, event: Value) { const instance = componentInstances.get(componentKey); if (!instance) return; if (instance.update.kind !== 'closure') throw new Error('Component update must be a closure'); try { const callEnv = new Map(instance.update.env); callEnv.set(instance.update.params[0], instance.state); callEnv.set(instance.update.params[1], event); const result = evaluate(instance.update.body, callEnv, source); if (result.kind !== 'record') throw new Error('Component update must return { state, emit }'); const newState = result.fields.state; const emitList = result.fields.emit; instance.state = newState; if (emitList && emitList.kind === 'list') { for (const event of emitList.elements) { handleEvent(event); } } } catch(error) { if (error instanceof CGError) { console.error(error.format()); } else { throw error; } } } function expandStateful(ui: UIValue, path: number[]): UIValue { switch (ui.kind) { case 'stateful': { const fullKey = [...path, ui.key].join('.'); let instance = componentInstances.get(fullKey); if (!instance) { // first time, create it if (ui.init.kind !=='record') throw new Error('Stateful init must be a record'); instance = { state: ui.init, update: ui.update, view: ui.view }; componentInstances.set(fullKey, instance); } if (instance.view.kind !== 'closure') throw new Error('Stateful view must be a closure'); const callEnv = new Map(instance.view.env); callEnv.set(instance.view.params[0], instance.state); const viewResult = evaluate(instance.view.body, callEnv, source); let viewUI = valueToUI(viewResult); if (ui.focusable) { viewUI = { kind: 'clickable', child: viewUI, event: { kind: 'constructor', name: 'Focus', args: [{ kind: 'string', value: fullKey }] } }; } return expandStateful(viewUI, path); } case 'stack': case 'row': case 'column': { return { ...ui, children: ui.children.map((child: UIValue, i: number) => expandStateful(child, [...path, i]) ) } } case 'clickable': case 'padding': case 'positioned': case 'opacity': case 'clip': { return { ...ui, child: expandStateful((ui as any).child, [...path, 0]) }; } default: // leaf nodes return ui; } } function setupCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; } setupCanvas(); function rerender() { if (app.view.kind !== 'closure') throw new Error('view must be a function'); const viewport: Value = { kind: 'record', fields: { width: { kind: 'int', value: window.innerWidth }, height: { kind: 'int', value: window.innerHeight } } }; try { const callEnv = new Map(app.view.env); callEnv.set(app.view.params[0], state); callEnv.set(app.view.params[1], viewport); const uiValue = evaluate(app.view.body, callEnv, source); const ui = valueToUI(uiValue); const expandedUI = expandStateful(ui, []); render(expandedUI, canvas); } catch (error) { if (error instanceof CGError) { console.error(error.format()); } else { throw error; } } } function handleEvent(event: Value) { if (event.kind === 'constructor' && event.name === 'Focus') { if (event.args.length > 0 && event.args[0].kind === 'string') { setFocus(event.args[0].value); return; } } if (app.update.kind !== 'closure') throw new Error('update must be a function'); if (app.update.params.length !== 2) throw new Error('update must have 2 parameters'); try { const callEnv = new Map(app.update.env); callEnv.set(app.update.params[0], state); callEnv.set(app.update.params[1], event); const newState = evaluate(app.update.body, callEnv, source); state = newState; rerender(); } catch (error) { if (error instanceof CGError) { console.error(error.format()); } else { throw error; } } } canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const hitResult = hitTest(x, y); if (hitResult) { const { event, relativeX, relativeY } = hitResult; if (event.kind === 'constructor' && event.name === 'Focus') { handleEvent(event); } else if (event.kind === 'constructor') { const eventWithCoords: Value = { kind: 'constructor', name: event.name, args: [{ kind: 'record', fields: { x: { kind: 'int', value: Math.floor(relativeX) }, y: { kind: 'int', value: Math.floor(relativeY) }, } }] }; handleEvent(eventWithCoords); } else { handleEvent(event); } } }); window.addEventListener('keydown', (e) => { let event: Value | null = null; if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { event = { kind: 'constructor', name: 'Char', args: [{ kind: 'string', value: e.key }] } } else { event = { kind: 'constructor', name: e.key, args: [] } } if (focusedComponentKey) { handleComponentEvent(focusedComponentKey, event); } else { handleEvent(event); } e.preventDefault(); }); window.addEventListener('resize', () => { setupCanvas(); rerender(); }) rerender(); }