|
|
|
|
@ -13,6 +13,152 @@ export type App = {
|
|
|
|
|
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;
|
|
|
|
|
let focusableComponents: Map<string, { x: number, y: number, w: number, h: number }> = new Map();
|
|
|
|
|
|
|
|
|
|
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, i) =>
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
@ -43,8 +189,9 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) {
|
|
|
|
|
callEnv.set(app.view.params[1], viewport);
|
|
|
|
|
const uiValue = evaluate(app.view.body, callEnv, source);
|
|
|
|
|
const ui = valueToUI(uiValue);
|
|
|
|
|
const expandedUI = expandStateful(ui, []);
|
|
|
|
|
|
|
|
|
|
render(ui, canvas);
|
|
|
|
|
render(expandedUI, canvas);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof CGError) {
|
|
|
|
|
console.error(error.format());
|
|
|
|
|
@ -55,6 +202,13 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
@ -95,7 +249,9 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) {
|
|
|
|
|
if (hitResult) {
|
|
|
|
|
const { event, relativeX, relativeY } = hitResult;
|
|
|
|
|
|
|
|
|
|
if (event.kind === 'constructor') {
|
|
|
|
|
if (event.kind === 'constructor' && event.name === 'Focus') {
|
|
|
|
|
handleEvent(event);
|
|
|
|
|
} else if (event.kind === 'constructor') {
|
|
|
|
|
const eventWithCoords: Value = {
|
|
|
|
|
kind: 'constructor',
|
|
|
|
|
name: event.name,
|
|
|
|
|
@ -131,7 +287,12 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (focusedComponentKey) {
|
|
|
|
|
handleComponentEvent(focusedComponentKey, event);
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
handleEvent(event);
|
|
|
|
|
}
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|