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.
271 lines
8.1 KiB
TypeScript
271 lines
8.1 KiB
TypeScript
import { render, hitTest } from './ui';
|
|
|
|
type UIValue = any;
|
|
|
|
type ComponentInstance = {
|
|
state: any;
|
|
update: (state: any) => (event: any) => any;
|
|
view: (state: any) => any;
|
|
};
|
|
|
|
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('.');
|
|
renderedKeys.add(fullKey);
|
|
|
|
let instance = componentInstances.get(fullKey);
|
|
const isNew = !instance;
|
|
|
|
if (!instance) {
|
|
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 (ui.autoFocus?._tag === 'True' && isNew) {
|
|
setFocus(fullKey);
|
|
}
|
|
|
|
const viewResult = instance.view(instance.state);
|
|
let viewUI = viewResult;
|
|
|
|
if (ui.focusable) {
|
|
viewUI = {
|
|
kind: 'clickable',
|
|
child: viewUI,
|
|
event: { _tag: 'FocusAndClick', _0: fullKey }
|
|
};
|
|
}
|
|
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 '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 === 'Rebind') {
|
|
store.rebind(event._0, event._1, event._2);
|
|
return;
|
|
}
|
|
|
|
if (event._tag === 'Focus') {
|
|
setFocus(event._0);
|
|
return;
|
|
}
|
|
|
|
if (event._tag === 'NoOp')
|
|
return;
|
|
}
|
|
|
|
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._tag === 'FocusAndClick') {
|
|
handleEvent({
|
|
_tag: 'FocusAndClick',
|
|
_0: event._0,
|
|
_1: { x: Math.floor(relativeX), y: Math.floor(relativeY) }
|
|
});
|
|
} else {
|
|
handleEvent(event);
|
|
}
|
|
}
|
|
|
|
rerender();
|
|
});
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
const event = {
|
|
_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 ? 'True' : 'False' }
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
e.preventDefault();
|
|
rerender();
|
|
});
|
|
|
|
let resizeRAF = 0;
|
|
window.addEventListener('resize', () => {
|
|
cancelAnimationFrame(resizeRAF);
|
|
resizeRAF = requestAnimationFrame(() => {
|
|
setupCanvas();
|
|
store.viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
rerender();
|
|
});
|
|
});
|
|
|
|
rerender();
|
|
}
|