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