import type { Value, UIValue } from './types'; import { valueToUI } from './valueToUI'; import { render, hitTest } from './ui'; import { evaluate } from './interpreter'; import { CGError } from './error'; import type { AST } from './ast'; import type { Env } from './env'; import type { Store } from './store'; import { saveStore } from './persistence'; import { valueToAST } from './valueToAST'; export type App = { init: Value; update: Value; // State / Event / State view: Value; // State / UI } export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env: Env, store: Store, dependents: Map>) { let state = app.init; type ComponentInstance = { state: Value; update: Value; view: Value; }; // Store-related builtins env.set('storeSearch', { kind: 'native', name: 'storeNames', arity: 1, fn: (query) => { const names: Value[] = []; const searchTerm = query.kind === 'string' ? query.value.toLowerCase() : ''; for (const name of store.keys()) { if (searchTerm === '' || name.toLowerCase().includes(searchTerm)) { names.push({ kind: 'string', value: name }); } } return { kind: 'list', elements: names }; } }); 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 recomputeDependents(changedName: string, visited: Set = new Set()) { const toRecompute = dependents.get(changedName); if (!toRecompute) return; for (const depName of toRecompute) { // Cycle detection if (visited.has(depName)) { console.warn(`Cycle detected ${depName} already recomputed`); continue; } visited.add(depName); const entry = store.get(depName); if (entry) { const newValue = evaluate(entry.body, env, source); env.set(depName, newValue); entry.value = newValue; recomputeDependents(depName, visited); } } } 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); } } rerender(); } 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); } else { // refresh closures, pick up new values instance.update = ui.update; instance.view = ui.view; } 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: 'FocusAndClick', 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(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) { handleEventInner(event); rerender(); } function handleEventInner(event: Value) { if (event.kind === 'constructor' && event.name === 'Batch') { if (event.args.length === 1 && event.args[0].kind === 'list') { for (const subEvent of event.args[0].elements) { handleEventInner(subEvent); } return; } } if (event.kind === 'constructor' && event.name === 'FocusAndClick') { if (event.args.length === 2 && event.args[0].kind === 'string') { const componentKey = event.args[0].value; const coords = event.args[1]; setFocus(componentKey); handleComponentEvent(componentKey, { kind: 'constructor', name: 'Clicked', args: [coords] }); return; } } if (event.kind === 'constructor' && event.name === 'Rebind') { if (event.args[0].kind !== 'string') return; const name = event.args[0].value; let newValue: Value; if (event.args.length === 2) { // Rebind "name" value newValue = event.args[1]; } else if (event.args.length === 3 && event.args[1].kind === 'list') { // Rebind "name" ["path"] const pathList = event.args[1] as { elements: Value[] }; const path = pathList.elements.map((e: Value) => e.kind === 'string' ? e.value : ''); const currentValue = env.get(name); if (!currentValue) return; newValue = updatePath(currentValue, path, event.args[2], env); } else { return; } env.set(name, newValue); const entry = store.get(name); if (entry) { entry.value = newValue; entry.body = valueToAST(newValue); } recomputeDependents(name); saveStore(store); return; } if (event.kind === 'constructor' && event.name === 'Focus') { if (event.args.length === 1 && event.args[0].kind === 'string') { setFocus(event.args[0].value) } return; } if (event.kind === 'constructor' && event.name === 'NoOp') 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; } 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' || event.name === 'Rebind')) { handleEvent(event); } else if (event.kind === 'constructor' && event.name === 'FocusAndClick') { const eventWithCoords: Value = { kind: 'constructor', name: event.name, args: [ event.args[0], { 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(); } function updatePath(obj: Value, path: string[], value: Value, env: Env): Value { if (path.length === 0) return value; if (obj.kind !== 'record') throw new Error('Cannot access field on non-record'); const [field, ...rest] = path; const newFields = { ...obj.fields, [field]: updatePath(obj.fields[field], rest, value, env) }; // Reevaluate any dependent fields if (rest.length === 0 && obj.fieldMeta) { const visited = new Set(); recomputeRecordFields(field, newFields, obj.fieldMeta, visited, env); } return { kind: 'record', fields: newFields, fieldMeta: obj.fieldMeta }; } function recomputeRecordFields( changedField: string, fields: { [key: string]: Value }, fieldMeta: { [key: string]: { body: AST, dependencies: Set } }, visited: Set, env: Env ) { for (const [fieldName, meta] of Object.entries(fieldMeta)) { if (visited.has(fieldName)) continue; if (meta.dependencies.has(changedField)) { visited.add(fieldName); const fieldEnv: Env = new Map(env); for (const [k, v] of Object.entries(fields)) { fieldEnv.set(k, v); } const newValue = evaluate(meta.body, fieldEnv, ''); fields[fieldName] = newValue; recomputeRecordFields(fieldName, fields, fieldMeta, visited, env); } } }