reactive nested records
This commit is contained in:
parent
84ef946281
commit
1029b1671f
4 changed files with 110 additions and 24 deletions
|
|
@ -40,14 +40,34 @@ export function evaluate(ast: AST, env: Env, source: string): Value {
|
||||||
return { kind: 'list', elements };
|
return { kind: 'list', elements };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'record':
|
case 'record': {
|
||||||
const fields: { [key: string]: Value } = {};
|
const fields: { [key: string]: Value } = {};
|
||||||
|
const fieldMeta: { [key: string]: { body: AST, dependencies: Set<string> } } = {};
|
||||||
|
const recordEnv = new Map(env);
|
||||||
|
const fieldNames = Object.keys(ast.fields);
|
||||||
|
|
||||||
Object.entries(ast.fields).forEach(([k, v]) => {
|
for (const [k, fieldAst] of Object.entries(ast.fields)) {
|
||||||
fields[k] = evaluate(v, env, source);
|
// Track which siblings are accessed
|
||||||
});
|
const deps = new Set<string>();
|
||||||
|
|
||||||
return { kind: 'record', fields };
|
const trackingEnv = new Map(recordEnv);
|
||||||
|
const originalGet = trackingEnv.get.bind(trackingEnv);
|
||||||
|
trackingEnv.get = (name: string) => {
|
||||||
|
if (fieldNames.includes(name) && name !== k) {
|
||||||
|
deps.add(name);
|
||||||
|
}
|
||||||
|
return originalGet(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = evaluate(fieldAst, trackingEnv, source);
|
||||||
|
|
||||||
|
fields[k] = value;
|
||||||
|
fieldMeta[k] = { body: fieldAst, dependencies: deps };
|
||||||
|
recordEnv.set(k, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'record', fields, fieldMeta };
|
||||||
|
}
|
||||||
|
|
||||||
case 'record-access': {
|
case 'record-access': {
|
||||||
const record = evaluate(ast.record, env, source);
|
const record = evaluate(ast.record, env, source);
|
||||||
|
|
@ -229,8 +249,6 @@ export function evaluate(ast: AST, env: Env, source: string): Value {
|
||||||
if (!rootValue)
|
if (!rootValue)
|
||||||
throw RuntimeError(`Unknown variable: ${rootName}`, ast.line, ast.column, source);
|
throw RuntimeError(`Unknown variable: ${rootName}`, ast.line, ast.column, source);
|
||||||
|
|
||||||
// const newRoot = updatePath(rootValue, path, value, ast.line, ast.column, source);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'constructor',
|
kind: 'constructor',
|
||||||
name: 'Rebind',
|
name: 'Rebind',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { valueToUI } from './valueToUI';
|
||||||
import { render, hitTest } from './ui';
|
import { render, hitTest } from './ui';
|
||||||
import { evaluate } from './interpreter';
|
import { evaluate } from './interpreter';
|
||||||
import { CGError } from './error';
|
import { CGError } from './error';
|
||||||
|
import type { AST } from './ast';
|
||||||
import type { Env } from './env';
|
import type { Env } from './env';
|
||||||
import type { Store } from './store';
|
import type { Store } from './store';
|
||||||
|
|
||||||
|
|
@ -53,18 +54,25 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
rerender();
|
rerender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function recomputeDependents(changedName: string) {
|
function recomputeDependents(changedName: string, visited: Set<string> = new Set()) {
|
||||||
const toRecompute = dependents.get(changedName);
|
const toRecompute = dependents.get(changedName);
|
||||||
if (!toRecompute) return;
|
if (!toRecompute) return;
|
||||||
|
|
||||||
for (const depName of toRecompute) {
|
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);
|
const entry = store.get(depName);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const newValue = evaluate(entry.body, env, source);
|
const newValue = evaluate(entry.body, env, source);
|
||||||
env.set(depName, newValue);
|
env.set(depName, newValue);
|
||||||
entry.value = newValue;
|
entry.value = newValue;
|
||||||
|
|
||||||
recomputeDependents(depName);
|
recomputeDependents(depName, visited);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +230,20 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(event: Value) {
|
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.kind === 'constructor' && event.name === 'FocusAndClick') {
|
||||||
if (event.args.length === 2 && event.args[0].kind === 'string') {
|
if (event.args.length === 2 && event.args[0].kind === 'string') {
|
||||||
const componentKey = event.args[0].value;
|
const componentKey = event.args[0].value;
|
||||||
|
|
@ -253,7 +275,7 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
const path = pathList.elements.map((e: Value) => e.kind === 'string' ? e.value : '');
|
const path = pathList.elements.map((e: Value) => e.kind === 'string' ? e.value : '');
|
||||||
const currentValue = env.get(name);
|
const currentValue = env.get(name);
|
||||||
if (!currentValue) return;
|
if (!currentValue) return;
|
||||||
newValue = updatePath(currentValue, path, event.args[2]);
|
newValue = updatePath(currentValue, path, event.args[2], env);
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -265,11 +287,19 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
}
|
}
|
||||||
|
|
||||||
recomputeDependents(name);
|
recomputeDependents(name);
|
||||||
|
|
||||||
rerender();
|
|
||||||
return;
|
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')
|
if (app.update.kind !== 'closure')
|
||||||
throw new Error('update must be a function');
|
throw new Error('update must be a function');
|
||||||
|
|
||||||
|
|
@ -283,7 +313,6 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
const newState = evaluate(app.update.body, callEnv, source);
|
const newState = evaluate(app.update.body, callEnv, source);
|
||||||
|
|
||||||
state = newState;
|
state = newState;
|
||||||
rerender();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof CGError) {
|
if (error instanceof CGError) {
|
||||||
console.error(error.format());
|
console.error(error.format());
|
||||||
|
|
@ -345,7 +374,6 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
|
|
||||||
if (focusedComponentKey) {
|
if (focusedComponentKey) {
|
||||||
handleComponentEvent(focusedComponentKey, event);
|
handleComponentEvent(focusedComponentKey, event);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
handleEvent(event);
|
handleEvent(event);
|
||||||
}
|
}
|
||||||
|
|
@ -360,18 +388,54 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
||||||
rerender();
|
rerender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePath(obj: Value, path: string[], value: Value): Value {
|
function updatePath(obj: Value, path: string[], value: Value, env: Env): Value {
|
||||||
if (path.length === 0) return value;
|
if (path.length === 0) return value;
|
||||||
|
|
||||||
if (obj.kind !== 'record')
|
if (obj.kind !== 'record')
|
||||||
throw new Error('Cannot access field on non-record');
|
throw new Error('Cannot access field on non-record');
|
||||||
|
|
||||||
const [field, ...rest] = path;
|
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 {
|
return {
|
||||||
kind: 'record',
|
kind: 'record',
|
||||||
fields: {
|
fields: newFields,
|
||||||
...obj.fields,
|
fieldMeta: obj.fieldMeta
|
||||||
[field]: updatePath(obj.fields[field], rest, value)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ init = {};
|
||||||
|
|
||||||
testApp = {
|
testApp = {
|
||||||
email = "",
|
email = "",
|
||||||
password = ""
|
password = "",
|
||||||
|
combinedText = email & " " & password
|
||||||
};
|
};
|
||||||
|
|
||||||
combinedText = testApp.email & " " & testApp.password;
|
|
||||||
|
|
||||||
update = state event \ event
|
update = state event \ event
|
||||||
| _ \ state;
|
| _ \ state;
|
||||||
|
|
||||||
|
|
@ -35,7 +34,7 @@ view = state viewport \
|
||||||
},
|
},
|
||||||
Text { content = "Username: " & testApp.email, x = 8, y = 16 },
|
Text { content = "Username: " & testApp.email, x = 8, y = 16 },
|
||||||
Text { content = "Password: " & testApp.password, x = 8, y = 16 },
|
Text { content = "Password: " & testApp.password, x = 8, y = 16 },
|
||||||
Text { content = "Combined: " & combinedText, x = 8, y = 16 }
|
Text { content = "Combined: " & testApp.combinedText, x = 8, y = 16 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ export type ListValue = {
|
||||||
export type RecordValue = {
|
export type RecordValue = {
|
||||||
kind: 'record'
|
kind: 'record'
|
||||||
fields: { [key: string]: Value }
|
fields: { [key: string]: Value }
|
||||||
|
fieldMeta?: {
|
||||||
|
[key: string]: {
|
||||||
|
body: AST
|
||||||
|
dependencies: Set<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConstructorValue = {
|
export type ConstructorValue = {
|
||||||
|
|
@ -46,7 +52,6 @@ export type NativeFunction = {
|
||||||
fn: (...args: Value[]) => Value
|
fn: (...args: Value[]) => Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type UIValue =
|
export type UIValue =
|
||||||
| { kind: 'rect', w: number, h: number, color: string, radius?: number }
|
| { kind: 'rect', w: number, h: number, color: string, radius?: number }
|
||||||
| { kind: 'text', content: string, x: number, y: number }
|
| { kind: 'text', content: string, x: number, y: number }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue