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 };
|
||||
}
|
||||
|
||||
case 'record':
|
||||
case 'record': {
|
||||
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]) => {
|
||||
fields[k] = evaluate(v, env, source);
|
||||
});
|
||||
for (const [k, fieldAst] of Object.entries(ast.fields)) {
|
||||
// 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': {
|
||||
const record = evaluate(ast.record, env, source);
|
||||
|
|
@ -229,8 +249,6 @@ export function evaluate(ast: AST, env: Env, source: string): Value {
|
|||
if (!rootValue)
|
||||
throw RuntimeError(`Unknown variable: ${rootName}`, ast.line, ast.column, source);
|
||||
|
||||
// const newRoot = updatePath(rootValue, path, value, ast.line, ast.column, source);
|
||||
|
||||
return {
|
||||
kind: 'constructor',
|
||||
name: 'Rebind',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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';
|
||||
|
||||
|
|
@ -53,18 +54,25 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
rerender();
|
||||
}
|
||||
|
||||
function recomputeDependents(changedName: string) {
|
||||
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);
|
||||
recomputeDependents(depName, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,6 +230,20 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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 currentValue = env.get(name);
|
||||
if (!currentValue) return;
|
||||
newValue = updatePath(currentValue, path, event.args[2]);
|
||||
newValue = updatePath(currentValue, path, event.args[2], env);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
|
@ -265,11 +287,19 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
}
|
||||
|
||||
recomputeDependents(name);
|
||||
|
||||
rerender();
|
||||
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');
|
||||
|
||||
|
|
@ -283,7 +313,6 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
const newState = evaluate(app.update.body, callEnv, source);
|
||||
|
||||
state = newState;
|
||||
rerender();
|
||||
} catch (error) {
|
||||
if (error instanceof CGError) {
|
||||
console.error(error.format());
|
||||
|
|
@ -345,7 +374,6 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
|
||||
if (focusedComponentKey) {
|
||||
handleComponentEvent(focusedComponentKey, event);
|
||||
|
||||
} else {
|
||||
handleEvent(event);
|
||||
}
|
||||
|
|
@ -360,18 +388,54 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string, env:
|
|||
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 (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: {
|
||||
...obj.fields,
|
||||
[field]: updatePath(obj.fields[field], rest, value)
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ init = {};
|
|||
|
||||
testApp = {
|
||||
email = "",
|
||||
password = ""
|
||||
password = "",
|
||||
combinedText = email & " " & password
|
||||
};
|
||||
|
||||
combinedText = testApp.email & " " & testApp.password;
|
||||
|
||||
update = state event \ event
|
||||
| _ \ state;
|
||||
|
||||
|
|
@ -35,7 +34,7 @@ view = state viewport \
|
|||
},
|
||||
Text { content = "Username: " & testApp.email, 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 = {
|
||||
kind: 'record'
|
||||
fields: { [key: string]: Value }
|
||||
fieldMeta?: {
|
||||
[key: string]: {
|
||||
body: AST
|
||||
dependencies: Set<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ConstructorValue = {
|
||||
|
|
@ -46,7 +52,6 @@ export type NativeFunction = {
|
|||
fn: (...args: Value[]) => Value
|
||||
}
|
||||
|
||||
|
||||
export type UIValue =
|
||||
| { kind: 'rect', w: number, h: number, color: string, radius?: number }
|
||||
| { kind: 'text', content: string, x: number, y: number }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue