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
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();
|
|
}
|