cg/src/runtime-compiled.ts

334 lines
11 KiB
TypeScript

import { render, hitTest, scrollHitTest } from './ui';
import { syncToAst, saveDefinitions } from './runtime-js';
import { definitions } from './compiler';
type UIValue = any;
type ComponentInstance = {
state: any;
update: (state: any) => (event: any) => any;
view: (state: any) => any;
focusable: boolean;
};
export function runAppCompiled(canvas: HTMLCanvasElement, store: any) {
const componentInstances = new Map<string, ComponentInstance>();
let focusedComponentKey: string | null = null;
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 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, { _tag: 'Blurred' });
}
// Focus event to the new
if (componentKey && componentInstances.has(componentKey)) {
handleComponentEvent(componentKey, { _tag: 'Focused' });
}
// Notify ancestors
if (componentKey) {
for (const key of componentInstances.keys()) {
if (key !== componentKey && componentKey.startsWith(key + '.')) {
handleComponentEvent(key, { _tag: 'ChildFocused' });
}
}
}
}
function handleComponentEvent(componentKey: string, event: any) {
const instance = componentInstances.get(componentKey);
if (!instance) return;
try {
const result = instance.update(instance.state)(event);
instance.state = result.state;
if (result.emit && Array.isArray(result.emit)) {
for (const e of result.emit) {
handleEvent(e);
}
}
} catch(error) {
console.error('Component event error:', error);
}
}
function expandStateful(ui: UIValue, path: number[], renderedKeys: Set<string>): UIValue {
switch (ui.kind) {
case 'stateful': {
// const fullKey = [...path, ui.key].join('.');
const fullKey = [...path.filter((p: any) => typeof p === 'string'), ui.key].join('.');
renderedKeys.add(fullKey);
let instance = componentInstances.get(fullKey);
const isNew = !instance;
if (!instance) {
instance = {
state: ui.init,
update: ui.update,
view: ui.view,
focusable: ui.focusable?._tag === 'True'
};
componentInstances.set(fullKey, instance);
} else {
// refresh closures, pick up new values
instance.update = ui.update;
instance.view = ui.view;
instance.focusable = ui.focusable?._tag === 'True'
}
if (ui.autoFocus?._tag === 'True' && isNew) {
setFocus(fullKey);
}
const emit = (event: any) => ({ _tag: 'ComponentEvent', _0: fullKey, _1: event });
let viewResult;
try {
viewResult = instance.view(instance.state)(emit);
} catch (e: any) {
viewResult = {
kind: 'stack', children: [
{ kind: 'rect', w: 9999, h: 60, color: '#400' },
{ kind: 'text', content: `Error: ${e.message}`, x: 10, y: 30, color: '#f88' }
]
};
}
const viewUI = {
kind: 'clickable',
child: viewResult,
onClick: ui.focusable
? (coords: any) => ({ _tag: 'FocusAndClick', _0: fullKey, _1: coords })
: (coords: any) => ({ _tag: 'ClickOnly', _0: fullKey, _1: coords })
};
return expandStateful(viewUI, [...path, ui.key], renderedKeys);
}
case 'stack':
case 'row':
case 'column': {
return {
...ui,
children: ui.children.map((child: UIValue, i: number) =>
expandStateful(child, [...path, i], renderedKeys)
)
}
}
case 'clickable':
case 'scrollable':
case 'padding':
case 'positioned':
case 'opacity':
case 'clip': {
return {
...ui,
child: expandStateful((ui as any).child, [...path, 0], renderedKeys)
};
}
default:
// leaf nodes
return ui;
}
}
function rerender() {
const renderedKeys = new Set<string>();
try {
const expandedUI = expandStateful(store.os, [], renderedKeys);
// clean up unrendered instances
for (const key of componentInstances.keys()) {
if (!renderedKeys.has(key)) {
componentInstances.delete(key);
}
}
if (focusedComponentKey && !renderedKeys.has(focusedComponentKey)) {
const parts = focusedComponentKey.split('.');
focusedComponentKey = null;
while (parts.length > 0) {
parts.pop();
const ancestor = parts.join('.');
if (ancestor && renderedKeys.has(ancestor)) {
focusedComponentKey = ancestor;
break;
}
}
}
render(expandedUI, canvas);
} catch (error) {
console.error('Render error:', error);
}
}
function handleEvent(event: any) {
// Thunk
if (typeof event === 'function') {
handleEvent(event(null));
return;
}
if (!event || !event._tag) return;
if (event._tag === 'Batch' && event._0) {
for (const e of event._0) {
handleEvent(e);
}
return;
}
if (event._tag === 'FocusAndClick') {
const componentKey = event._0;
const coords = event._1;
setFocus(componentKey);
handleComponentEvent(componentKey, { _tag: 'Clicked', _0: coords });
return;
}
if (event._tag === 'ClickOnly') {
const componentKey = event._0;
const coords = event._1;
handleComponentEvent(componentKey, { _tag: 'Clicked', _0: coords });
return;
}
if (event._tag === 'Rebind') {
store.rebind(event._0, event._1, event._2);
return;
}
if (event._tag === 'DeleteAt') {
const path = event._0;
const name = path[0];
if (path.length === 1) {
delete store[name];
definitions.delete(name);
saveDefinitions();
} else {
let obj = store[name];
for (let i = 1; i < path.length - 1; i++) {
if (obj === undefined || obj === null) return;
obj = obj[path[i]];
}
if (obj !== undefined && obj !== null) {
delete obj[path[path.length - 1]];
}
}
syncToAst(name);
return;
}
if (event._tag === 'Focus') {
setFocus(event._0);
return;
}
if (event._tag === 'ComponentEvent') {
const instance = componentInstances.get(event._0);
if (instance?.focusable) {
setFocus(event._0);
}
handleComponentEvent(event._0, event._1);
return;
}
if (event._tag === 'NoOp')
return;
}
function dispatchToFocused(event: any) {
if (focusedComponentKey) {
// send to focused component
handleComponentEvent(focusedComponentKey, event);
// bubble up to ancestors
for (const key of componentInstances.keys()) {
if (key !== focusedComponentKey && focusedComponentKey.startsWith(key + '.')) {
handleComponentEvent(key, event);
}
}
}
}
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 coords = { x: Math.floor(hitResult.relativeX), y: Math.floor(hitResult.relativeY) };
handleEvent(hitResult.onClick(coords));
}
rerender();
});
window.addEventListener('keydown', (e) => {
dispatchToFocused({
_tag: 'Key',
_0: {
key: e.key,
ctrl: { _tag: e.ctrlKey ? 'True' : 'False' },
meta: { _tag: e.metaKey ? 'True' : 'False' },
alt: { _tag: e.altKey ? 'True' : 'False' },
shift: { _tag: e.shiftKey ? 'True' : 'False' },
printable: { _tag: (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) ? 'True' : 'False' }
}
});
e.preventDefault();
rerender();
});
canvas.addEventListener('wheel', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const hit = scrollHitTest(x, y);
if (hit && typeof hit.onScroll === 'function') {
const delta = { deltaX: Math.round(e.deltaX), deltaY: Math.round(e.deltaY) };
handleEvent(hit.onScroll(delta));
}
e.preventDefault();
rerender();
});
let resizeRAF = 0;
window.addEventListener('resize', () => {
cancelAnimationFrame(resizeRAF);
resizeRAF = requestAnimationFrame(() => {
setupCanvas();
store.viewport = { width: window.innerWidth, height: window.innerHeight };
rerender();
});
});
rerender();
}