From 2cd5a609bb67a31813d6c5282d223b2f7dbc50c8 Mon Sep 17 00:00:00 2001 From: Dustin Swan Date: Sun, 8 Feb 2026 20:02:06 -0700 Subject: [PATCH] compiling. interpreting was too slow --- src/compiler.ts | 245 ++++++++++++++++++++++++++++++++++++++ src/main.ts | 34 +++--- src/os.cg | 8 +- src/persistence.ts | 2 +- src/runtime-compiled.ts | 227 +++++++++++++++++++++++++++++++++++ src/runtime-js.ts | 56 +++++++++ src/ui.ts | 5 +- src/valueToUI-compiled.ts | 96 +++++++++++++++ 8 files changed, 651 insertions(+), 22 deletions(-) create mode 100644 src/compiler.ts create mode 100644 src/runtime-compiled.ts create mode 100644 src/runtime-js.ts create mode 100644 src/valueToUI-compiled.ts diff --git a/src/compiler.ts b/src/compiler.ts new file mode 100644 index 0000000..e6ee7e1 --- /dev/null +++ b/src/compiler.ts @@ -0,0 +1,245 @@ +import type { AST, Pattern, Definition } from './ast'; +import { _rt, store } from './runtime-js'; + +export function compile(ast: AST): string { + switch (ast.kind) { + case 'literal': + if (ast.value.kind === 'string') + return JSON.stringify(ast.value.value); + if (ast.value.kind === 'int' || ast.value.kind === 'float') + return JSON.stringify(ast.value.value); + throw new Error(`Cannot compile literal of kind ${ast.value.kind}`); + + case 'variable': + return sanitize(ast.name); + + case 'lambda': + const params = ast.params.map(sanitize).join(') => ('); + return `((${params}) => ${compile(ast.body)})`; + + case 'apply': + // Constructor + if (ast.func.kind === 'constructor') { + const ctorName = ast.func.name; + const arg = compile(ast.args[0]); + return `((_a) => _a && typeof _a === 'object' && !Array.isArray(_a) && !_a._tag + ? { _tag: "${ctorName}", ..._a } + : { _tag: "${ctorName}", _0: _a })(${arg})`; + } + + const args = ast.args.map(compile).join(')('); + return `${compile(ast.func)}(${args})`; + + case 'record': { + const fields = Object.entries(ast.fields) + .map(([k, v]) => `${sanitize(k)}: ${compile(v)}`); + return `({${fields.join(', ')}})`; + } + + case 'list': { + const elements = ast.elements.map(e => + 'spread' in e ? `...${compile(e.spread)}` : compile(e) + ); + return `[${elements.join(', ')}]`; + } + + case 'record-access': + return `${compile(ast.record)}.${sanitize(ast.field)}`; + + case 'record-update': + const updates = Object.entries(ast.updates) + .map(([k, v]) => `${sanitize(k)}: ${compile(v)}`); + return `({...${compile(ast.record)}, ${updates.join(', ')}})`; + + case 'let': + return `((${sanitize(ast.name)}) => + ${compile(ast.body)})(${compile(ast.value)})`; + + case 'match': + return compileMatch(ast); + + case 'constructor': + return `({ _tag: "${ast.name}" })`; + /* + return `((arg) => arg && typeof arg === 'object' && !arg._tag + ? { _tag: "${ast.name}", ...arg } + : { _tag: "${ast.name}", _0: arg })`; + */ + + case 'rebind': { + if (ast.target.kind === 'variable') { + return `({ _tag: "Rebind", _0: "${ast.target.name}", _1: ${compile(ast.value)} })`; + } else if (ast.target.kind === 'record-access') { + let current: AST = ast.target; + const path: string[] = []; + while (current.kind === 'record-access') { + path.unshift(current.field); + current = current.record; + } + if (current.kind === 'variable') { + return `({ _tag: "Rebind", _0: "${current.name}", _1: ${JSON.stringify(path)}, _2: ${compile(ast.value)} })`; + } + } + throw new Error('Invalid rebind target'); + } + + default: + throw new Error(`Cannot compile ${ast.kind}`); + + } +} + +function sanitize(name: string): string { + const ops: Record = { + 'add': '_rt.add', 'sub': '_rt.sub', 'mul': '_rt.mul', + 'div': '_rt.div', 'mod': '_rt.mod', 'eq': '_rt.eq', + 'cat': '_rt.cat', 'gt': '_rt.gt', 'lt': '_rt.lt', + 'gte': '_rt.gte', 'lte': '_rt.lte', 'max': '_rt.max', 'min': '_rt.min', + }; + + if (ops[name]) return ops[name]; + + const natives = ['measureText', 'storeSearch', 'debug', 'len', 'slice', 'str']; + if (natives.includes(name)) return `_rt.${name}`; + + const reserved = [ + 'default','class','function','return','const','let','var', + 'if','else','switch','case','for','while','do','break', + 'continue','new','delete','typeof','in','this','super', + 'import','export','extends','static','yield','await','async', + 'try','catch','finally','throw','null','true','false' + ]; + if (reserved.includes(name)) return `_${name}`; + + return name.replace(/-/g, '_'); +} + +function compileMatch(ast: AST & { kind: 'match'}): string { + const expr = compile(ast.expr); + const tmpVar = `_m${Math.floor(Math.random() * 10000)}`; + + let code = `((${tmpVar}) => { `; + + for (const c of ast.cases) { + const { condition, bindings } = compilePattern(c.pattern, tmpVar); + code += `if (${condition}) { `; + if (bindings.length > 0) { + code += `const ${bindings.join(', ')}; `; + } + code += `return ${compile(c.result)}; }`; + } + + code += `console.error("No match for:", ${tmpVar}); throw new Error("No match"); })(${expr})`; + return code; +} + +function compilePattern(pattern: Pattern, expr: string): { condition: string, bindings: string[] } { + switch (pattern.kind) { + case 'wildcard': + return { condition: 'true', bindings: [] }; + + case 'var': + return { condition: 'true', bindings: [`${sanitize(pattern.name)} = ${expr}`] }; + + case 'literal': + return { condition: `${expr} === ${JSON.stringify(pattern.value)}`, bindings: [] }; + + case 'constructor': { + let condition = `${expr}?._tag === "${pattern.name}"`; + const bindings: string[] = []; + pattern.args.forEach((argPattern, i) => { + const sub = compilePattern(argPattern, `${expr}._${i}`); + condition += ` && ${sub.condition}`; + bindings.push(...sub.bindings); + }); + + return { condition, bindings }; + } + + case 'list': { + let condition = `Array.isArray(${expr}) && ${expr}.length === ${pattern.elements.length}`; + const bindings: string[] = []; + pattern.elements.forEach((elemPattern, i) => { + const sub = compilePattern(elemPattern, `${expr}[${i}]`); + if (sub.condition !== 'true') condition += ` && ${sub.condition}`; + bindings.push(...sub.bindings); + }); + + return { condition, bindings }; + } + + case 'list-spread': { + let condition = `Array.isArray(${expr}) && ${expr}.length >= ${pattern.head.length}`; + const bindings: string[] = []; + pattern.head.forEach((elemPattern, i) => { + const sub = compilePattern(elemPattern, `${expr}[${i}]`); + if (sub.condition !== 'true') condition += ` && ${sub.condition}`; + bindings.push(...sub.bindings); + }); + bindings.push(`${sanitize(pattern.spread)} = ${expr}.slice(${pattern.head.length})`); + + return { condition, bindings }; + } + + case 'record': { + let condition = 'true'; + const bindings: string[] = []; + for (const [field, fieldPattern] of Object.entries(pattern.fields)) { + const sub = compilePattern(fieldPattern, `${expr}.${sanitize(field)}`); + if (sub.condition !== 'true') condition += ` && ${sub.condition}`; + bindings.push(...sub.bindings); + } + + return { condition, bindings }; + } + + default: + return { condition: 'true', bindings: [] }; + } +} + +export function compileAndRun(defs: Definition[]) { + const compiledDefs: string[] = []; + + for (const def of defs) { + const compiled = `const ${sanitize(def.name)} = ${compile(def.body)};`; + compiledDefs.push(compiled); + + try { + new Function('_rt', compiled); + } catch (e) { + console.error(`=== BROKEN: ${def.name} ===`); + console.error(compiled); + throw e; + } + } + + + /* + const compiledDefs = defs.map(def => + `const ${sanitize(def.name)} = ${compile(def.body)};` + ).join('\n'); + */ + + const lastName = defs[defs.length - 1].name; + const defNames = defs.map(d => sanitize(d.name)).join(', '); + + const code = `${compiledDefs.join('\n')} +return { ${defNames}, __result: ${sanitize(lastName)} };`; + + // console.log('--- Compiled Code ---'); + // console.log(code); + // console.log('====================='); + + const fn = new Function('_rt', code); + const allDefs = fn(_rt); + + // Populate store + for (const [name, value] of Object.entries(allDefs)) { + if (name !== '__result') { + store[name] = value; + } + } + + return allDefs.__result; +} diff --git a/src/main.ts b/src/main.ts index 0d2599d..5aad185 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,26 +1,23 @@ -import type { Env } from './env' -import { evaluate } from './interpreter' +import { compileAndRun } from './compiler' import { tokenize } from './lexer' import { Parser } from './parser' -import { runApp } from './runtime'; -import { builtins } from './builtins'; -import { CGError } from './error'; -import { createStore, startTracking, stopTracking, buildDependents } from './store'; -import { loadStore, clearStore } from './persistence'; +import { runAppCompiled } from './runtime-compiled' +import { _rt } from './runtime-js' import stdlibCode from './stdlib.cg?raw'; import designTokensCode from './design-tokens.cg?raw'; import uiComponentsCode from './ui-components.cg?raw'; - import osCode from './os.cg?raw'; const canvas = document.createElement('canvas') as HTMLCanvasElement; document.body.appendChild(canvas); +/* const clearButton = document.getElementById('clear-storage'); if (clearButton) { clearButton.onclick = () => clearStore(); } +*/ const cgCode = stdlibCode + '\n' + designTokensCode + '\n' + @@ -30,8 +27,16 @@ const cgCode = stdlibCode + '\n' + try { const tokens = tokenize(cgCode); const parser = new Parser(tokens, cgCode); - const definitions = parser.parse(); - + const defs = parser.parse(); + const os = compileAndRun(defs); + console.log("Compiled os:", os); + runAppCompiled( + { init: os.init, update: os.update, view: os.view }, + canvas, + _rt + ); + + /* const env: Env = new Map(Object.entries(builtins)); const store = createStore(); @@ -76,10 +81,9 @@ try { const view = appRecord.fields.view; runApp({ init, update, view }, canvas, cgCode, env, store, dependents); + */ + + } catch(error) { - if (error instanceof CGError) { - console.error(error.format()); - } else { - throw error; - } + console.error(error); } diff --git a/src/os.cg b/src/os.cg index 4fd1fcd..17d9ee2 100644 --- a/src/os.cg +++ b/src/os.cg @@ -20,13 +20,13 @@ listRow = config \ child = Stack { children = [ Rect { w = config.w, h = config.h, color = color }, - centerV config.h ( + # centerV config.h ( Positioned { x = 10, - y = 0, + y = 10, child = Text { content = config.child, color = "white" } } - ) + # ) ] } }; @@ -34,6 +34,8 @@ listRow = config \ palette = state viewport \ results = take 10 (storeSearch osState.palette.query); + _ = debug "palette results" results; + state = osState.palette; padding = 0; diff --git a/src/persistence.ts b/src/persistence.ts index 2b0f5f3..d6d57f3 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -11,7 +11,7 @@ export function saveStore(store: Store) { source: 'file' }; } - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + // localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } export function loadStore(): Record | null { diff --git a/src/runtime-compiled.ts b/src/runtime-compiled.ts new file mode 100644 index 0000000..c1211a5 --- /dev/null +++ b/src/runtime-compiled.ts @@ -0,0 +1,227 @@ +// import type { UIValue } from './types'; +import { valueToUI } from './valueToUI-compiled'; +import { render, hitTest } from './ui'; + +type UIValue = any; + +type App = { + init: any; + update: (state: any) => (event: any) => any; + view: (state: any) => (viewport: any) => any; +} + +type ComponentInstance = { + state: any; + update: (state: any) => (event: any) => any; + view: (state: any) => any; +}; + +export function runAppCompiled(app: App, canvas: HTMLCanvasElement, rt: any) { + let state = app.init; + const componentInstances = new Map(); + 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, { name: 'Focused' }); + } + rerender(); + } + + 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); + } + } + rerender(); + } catch(error) { + console.error('Component event error:', error); + } + } + + function expandStateful(ui: UIValue, path: number[]): UIValue { + switch (ui.kind) { + case 'stateful': { + const fullKey = [...path, ui.key].join('.'); + let instance = componentInstances.get(fullKey); + + if (!instance) { + console.log('Creating stateful', fullKey); + console.log('ui.init:', ui.init); + 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; + } + console.log('Instance state', instance.state); + + const viewResult = instance.view(instance.state); + let viewUI = valueToUI(viewResult); + + if (ui.focusable) { + viewUI = { + kind: 'clickable', + child: viewUI, + event: { _tag: 'FocusAndClick', _0: fullKey } + }; + } + return expandStateful(viewUI, path); + } + + case 'stack': + case 'row': + case 'column': { + return { + ...ui, + children: ui.children.map((child: UIValue, i: number) => + expandStateful(child, [...path, i]) + ) + } + } + + case 'clickable': + case 'padding': + case 'positioned': + case 'opacity': + case 'clip': { + return { + ...ui, + child: expandStateful((ui as any).child, [...path, 0]) + }; + } + + default: + // leaf nodes + return ui; + } + + } + + function rerender() { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + + try { + const uiValue = app.view(state)(viewport); + const ui = valueToUI(uiValue); + const expandedUI = expandStateful(ui, []); + render(expandedUI, canvas); + } catch (error) { + console.error('Render error:', error); + } + } + + function handleEvent(event: any) { + 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') { + rt.rebind(event._0, event._1, event._2); + rerender(); + return; + } + + if (event._tag === 'Focus') { + setFocus(event._0); + return; + } + + if (event._tag === 'NoOp') + return; + + state = app.update(state)(event); + rerender(); + } + + 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); + } + } + }); + + window.addEventListener('keydown', (e) => { + let event: any; + + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + event = { _tag: 'Char', _0: e.key }; + } else { + event = { _tag: e.key }; + } + + if (focusedComponentKey) { + handleComponentEvent(focusedComponentKey, event); + } else { + handleEvent(event); + } + e.preventDefault(); + }); + + window.addEventListener('resize', () => { + setupCanvas(); + rerender(); + }) + + rerender(); +} diff --git a/src/runtime-js.ts b/src/runtime-js.ts new file mode 100644 index 0000000..1433c1f --- /dev/null +++ b/src/runtime-js.ts @@ -0,0 +1,56 @@ +export const store: Record = {}; + +export const _rt = { + add: (a: number) => (b: number) => a + b, + sub: (a: number) => (b: number) => a - b, + mul: (a: number) => (b: number) => a * b, + div: (a: number) => (b: number) => a / b, + mod: (a: number) => (b: number) => a % b, + cat: (a: string) => (b: string) => a + b, + max: (a: number) => (b: number) => Math.max(a, b), + min: (a: number) => (b: number) => Math.min(a, b), + + eq: (a: any) => (b: any) => ({ _tag: a === b ? 'True' : 'False' }), + neq: (a: any) => (b: any) => ({ _tag: a !== b ? 'True' : 'False' }), + gt: (a: any) => (b: any) => ({ _tag: a > b ? 'True' : 'False' }), + lt: (a: any) => (b: any) => ({ _tag: a < b ? 'True' : 'False' }), + gte: (a: any) => (b: any) => ({ _tag: a >= b ? 'True' : 'False' }), + lte: (a: any) => (b: any) => ({ _tag: a <= b ? 'True' : 'False' }), + + len: (list: any[]) => list.length, + str: (x: any) => String(x), + slice: (s: string | any[]) => (start: number) => (end: number) => s.slice(start, end), + debug: (label: string) => (value: any) => { console.log(label, value); return value; }, + measureText: (text: string) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.font = '16px "SF Mono", "Monaco", "Menlo", monospace'; + return Math.floor(ctx.measureText(text).width); + } + return text.length * 10; // fallback + }, + storeSearch: (query: string) => { + const results: string[] = []; + const searchTerm = query.toLowerCase(); + console.log("in storeSearch. query: ", searchTerm); + for (const name of Object.keys(store)) { + if (searchTerm === '' || name.toLowerCase().includes(searchTerm)) { + results.push(name); + } + } + return results; + }, + rebind: (name: string, pathOrValue: any, maybeValue?: any) => { + if (maybeValue === undefined) { + store[name] = pathOrValue; + } else { + const path = pathOrValue as string[]; + let obj = store[name]; + for (let i = 0; i < path.length - 1; i++) { + obj = obj[path[i]]; + } + obj[path[path.length - 1]] = maybeValue; + } + }, +} diff --git a/src/ui.ts b/src/ui.ts index a9e4a1c..292f08c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -31,8 +31,7 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb if (ui.radius && ui.radius > 0) { const r = Math.min(ui.radius, ui.w / 2, ui.h / 2); - const inset = ui.strokeWidth ? ui.strokeWidth / 2 : 0; - // TODO + // const inset = ui.strokeWidth ? ui.strokeWidth / 2 : 0; TODO ctx.beginPath(); ctx.moveTo(x + r, y); @@ -201,7 +200,7 @@ export function measure(ui: UIValue): { width: number, height: number } { } } -export function hitTest(x: number, y: number): { event: Value, relativeX: number, relativeY: number } | null { +export function hitTest(x: number, y: number): { event: any, relativeX: number, relativeY: number } | null { for (const region of clickRegions) { if (x >= region.x && x < region.x + region.width && y >= region.y && y < region.y + region.height) { diff --git a/src/valueToUI-compiled.ts b/src/valueToUI-compiled.ts new file mode 100644 index 0000000..17943d8 --- /dev/null +++ b/src/valueToUI-compiled.ts @@ -0,0 +1,96 @@ +// import type { UIValue } from './types' + +export function valueToUI(value: any): any { + if (!value || !value._tag) + throw new Error(`Expected UI constructor, got: ${JSON.stringify(value)}`); + + switch(value._tag) { + case 'Rect': + return { + kind: 'rect', + w: value.w, + h: value.h, + color: value.color, + radius: value.radius, + strokeWidth: value.strokeWidth, + strokeColor: value.strokeColor, + }; + + case 'Text': + return { + kind: 'text', + content: value.content, + color: value.color, + }; + + case 'Row': + return { + kind: 'row', + gap: value.gap || 0, + children: value.children.map(valueToUI), + }; + + case 'Column': + return { + kind: 'column', + gap: value.gap || 0, + children: value.children.map(valueToUI), + }; + + case 'Stack': + return { + kind: 'stack', + children: value.children.map(valueToUI), + }; + + case 'Positioned': + return { + kind: 'positioned', + x: value.x || 0, + y: value.p || 0, + child: valueToUI(value.child), + }; + + case 'Padding': + return { + kind: 'padding', + amount: value.amount || 0, + child: valueToUI(value.child), + }; + + case 'Clickable': + return { + kind: 'clickable', + event: value.event, + child: valueToUI(value.child), + }; + + case 'Clip': + return { + kind: 'clip', + w: value.w, + h: value.h, + child: valueToUI(value.child), + }; + + case 'Opacity': + return { + kind: 'opacity', + opacity: value.opacity, + child: valueToUI(value.child), + }; + + case 'Stateful': + return { + kind: 'stateful', + key: value.key, + focusable: value.focusable, + init: value.init, + update: value.update, + view: value.view, + }; + + default: + throw new Error(`Unknown UI constructor: ${value._tag}`); + } +}