You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

297 lines
8.9 KiB
TypeScript

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<string, ComponentInstance>();
// 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();
}