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.

470 lines
14 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';
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<string, Set<string>>) {
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<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 recomputeDependents(changedName: string, visited: Set<string> = 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<string>();
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<string> } },
visited: Set<string>,
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);
}
}
}