From 1029b1671fd0e568974b4f1368a2b8635ef86d6b Mon Sep 17 00:00:00 2001 From: Dustin Swan Date: Fri, 6 Feb 2026 22:18:48 -0700 Subject: [PATCH] reactive nested records --- src/interpreter.ts | 32 ++++++++++++---- src/runtime.ts | 86 +++++++++++++++++++++++++++++++++++++------ src/textinput-test.cg | 7 ++-- src/types.ts | 7 +++- 4 files changed, 109 insertions(+), 23 deletions(-) diff --git a/src/interpreter.ts b/src/interpreter.ts index a753910..3ed55c4 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -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 } } = {}; + const recordEnv = new Map(env); + const fieldNames = Object.keys(ast.fields); + + for (const [k, fieldAst] of Object.entries(ast.fields)) { + // Track which siblings are accessed + const deps = new Set(); + + 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); + } - Object.entries(ast.fields).forEach(([k, v]) => { - fields[k] = evaluate(v, env, source); - }); + const value = evaluate(fieldAst, trackingEnv, source); - return { kind: 'record', fields }; + 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', diff --git a/src/runtime.ts b/src/runtime.ts index b0d2bb6..5d9164f 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -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 = 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); + 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; } + 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(); + 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 } }, + visited: Set, + 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); + } + } +} diff --git a/src/textinput-test.cg b/src/textinput-test.cg index b543260..ad4eaf4 100644 --- a/src/textinput-test.cg +++ b/src/textinput-test.cg @@ -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 } ] } }; diff --git a/src/types.ts b/src/types.ts index b62a5cb..fca7310 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,12 @@ export type ListValue = { export type RecordValue = { kind: 'record' fields: { [key: string]: Value } + fieldMeta?: { + [key: string]: { + body: AST + dependencies: Set + } + } } 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 }