reactive nested records

master
Dustin Swan 5 hours ago
parent 84ef946281
commit 1029b1671f
Signed by: dustinswan
GPG Key ID: 30D46587E2100467

@ -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);
return;
}
rerender(); if (event.kind === 'constructor' && event.name === 'Focus') {
if (event.args.length === 1 && event.args[0].kind === 'string') {
setFocus(event.args[0].value)
}
return; 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;
return {
kind: 'record', const newFields = {
fields: {
...obj.fields, ...obj.fields,
[field]: updatePath(obj.fields[field], rest, value) [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);
}
}
}

@ -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…
Cancel
Save