From 5b40e9d29857a4d272837ce01093ca2664212668 Mon Sep 17 00:00:00 2001 From: Dustin Swan Date: Sun, 1 Feb 2026 23:26:30 -0700 Subject: [PATCH] We have UI! kind of --- src/counter.cg | 16 ++++++++++ src/main.ts | 46 ++++++++++++++-------------- src/parser.ts | 38 +++++++++++++---------- src/runtime.ts | 56 +++++++++++++++++++++++++++++++++ src/types.ts | 2 +- src/ui.ts | 25 ++++++++++++++- src/valueToUI.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 222 insertions(+), 41 deletions(-) create mode 100644 src/counter.cg create mode 100644 src/runtime.ts create mode 100644 src/valueToUI.ts diff --git a/src/counter.cg b/src/counter.cg new file mode 100644 index 0000000..33ada62 --- /dev/null +++ b/src/counter.cg @@ -0,0 +1,16 @@ +init = 0; + +update = state event \ state + 1; + +view = count \ + Column { + gap = 20, + children = [ + Clickable { + event = "increment", + child = Rect { w = 100, h = 40, color = "blue" } + } + ] + }; + +{ init = init, update = update, view = view } diff --git a/src/main.ts b/src/main.ts index b6f2a16..f055548 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,30 +1,30 @@ -import type { UIValue } from './types'; -import { render } from './ui'; +import { evaluate } from './interpreter' +import type { Env } from './env' +import { tokenize } from './lexer' +import { Parser } from './parser' +import cgCode from './counter.cg?raw'; +import { runApp } from './runtime'; const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; document.body.appendChild(canvas); -const ui: UIValue = { - kind: 'column', - gap: 10, - children: [ - { kind: 'text', content: "Hello CG World", x: 0, y: 20 }, - { kind: 'rect', w: 200, h: 50, color: 'blue' }, - { kind: 'text', content: "YESS", x: 0, y: 20 }, - { kind: 'row', gap: 13, children: - [ - { kind: 'text', content: "In a row", x: 0, y: 10 }, - { kind: 'clickable', event: "test", child: { - kind: 'padding', amount: 10, child: { - kind: 'rect', w: 80, h: 30, color: '#4a90e2' - } - } - } - ] - } - ] -}; +const tokens = tokenize(cgCode); +const parser = new Parser(tokens); +const ast = parser.parse(); +console.log(ast); -render(ui, canvas); +const env: Env = new Map(); +const appRecord = evaluate(ast, env); + +console.log(appRecord); + +if (appRecord.kind !== 'record') + throw new Error('Expected record'); + +const init = appRecord.fields.init; +const update = appRecord.fields.update; +const view = appRecord.fields.view; + +runApp({ init, update, view }, canvas); diff --git a/src/parser.ts b/src/parser.ts index 50d8542..a5bafb0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -281,25 +281,31 @@ export class Parser { const field = (fieldToken as { value: string }).value; expr = { kind: 'record-access', record: expr, field }; } else if (this.current().kind === 'open-brace') { - // Record update - this.advance(); - const updates: { [key: string]: AST } = {}; - let first = true; - - while (this.current().kind !== 'close-brace') { - if (!first) { - this.expect('comma'); + if (expr.kind === 'constructor') { + // Constructor application + const record = this.parsePrimary(); + expr = { kind: 'apply', func: expr, args: [record] }; + } else { + // Record update + this.advance(); + const updates: { [key: string]: AST } = {}; + let first = true; + + while (this.current().kind !== 'close-brace') { + if (!first) { + this.expect('comma'); + } + first = false; + + const keyToken = this.expect('ident'); + const key = (keyToken as { value: string }).value; + this.expect('equals'); + updates[key] = this.parseExpression(); } - first = false; - const keyToken = this.expect('ident'); - const key = (keyToken as { value: string }).value; - this.expect('equals'); - updates[key] = this.parseExpression(); + this.expect('close-brace'); + expr = { kind: 'record-update', record: expr, updates } } - - this.expect('close-brace'); - expr = { kind: 'record-update', record: expr, updates } } else { break; } diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..eb5c664 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,56 @@ +import type { Value } from './types'; +import { valueToUI } from './valueToUI'; +import { render, hitTest } from './ui'; +import { evaluate } from './interpreter'; + +export type App = { + init: Value; + update: Value; // State / Event / State + view: Value; // State / UI +} + +export function runApp(app: App, canvas: HTMLCanvasElement) { + let state = app.init; + + function rerender() { + if (app.view.kind !== 'closure') + throw new Error('view must be a function'); + + const callEnv = new Map(app.view.env); + callEnv.set(app.view.params[0], state); + const uiValue = evaluate(app.view.body, callEnv); + const ui = valueToUI(uiValue); + + render(ui, canvas); + } + + function handleEvent(eventName: string) { + if (app.update.kind !== 'closure') + throw new Error('update must be a function'); + + if (app.update.params.length !== 2) + throw new Error('update must have 2 parameters'); + + const event: Value = { kind: 'string', value: eventName }; + const callEnv = new Map(app.update.env); + callEnv.set(app.update.params[0], state); + callEnv.set(app.update.params[1], event); + const newState = evaluate(app.update.body, callEnv); + + state = newState; + rerender(); + } + + canvas.addEventListener('click', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const eventName = hitTest(x, y); + if (eventName) { + handleEvent(eventName); + } + }); + + rerender(); +} diff --git a/src/types.ts b/src/types.ts index 9d94181..195ab80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,4 +47,4 @@ export type UIValue = | { kind: 'clickable', child: UIValue, event: string } | { kind: 'padding', child: UIValue, amount: number } -export type Value = IntValue | FloatValue | StringValue | Closure | ListValue | RecordValue | ConstructorValue | UIValue; +export type Value = IntValue | FloatValue | StringValue | Closure | ListValue | RecordValue | ConstructorValue; diff --git a/src/ui.ts b/src/ui.ts index 2d9a470..860d904 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,9 +1,20 @@ import type { UIValue } from './types'; +type ClickRegion = { + x: number; + y: number; + width: number; + height: number; + event: string; +}; + +let clickRegions: ClickRegion[] = []; + export function render(ui: UIValue, canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); + clickRegions = []; renderUI(ui, ctx, 0, 0); } } @@ -40,6 +51,8 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb } case 'clickable': { + const size = measure(ui.child); + clickRegions.push({ x, y, width: size.width, height: size.height, event: ui.event }) renderUI(ui.child, ctx, x, y); break; } @@ -86,5 +99,15 @@ function measure(ui: UIValue): { width: number, height: number } { } } - return { width: 0, height: 0 }; + // return { width: 0, height: 0 }; +} + +export function hitTest(x: number, y: number): string | null { + for (const region of clickRegions) { + if (x >= region.x && x < region.x + region.width && + x >= region.y && y < region.y + region.height) { + return region.event; + } + } + return null; } diff --git a/src/valueToUI.ts b/src/valueToUI.ts new file mode 100644 index 0000000..2bf0e20 --- /dev/null +++ b/src/valueToUI.ts @@ -0,0 +1,80 @@ +import type { Value, UIValue } from './types'; + +export function valueToUI(value: Value): UIValue { + console.log("valueToUI", value); + if (value.kind !== 'constructor') { + throw new Error('UI value must be a constructor'); + } + + if (value.args.length !== 1 || value.args[0].kind !== 'record') + throw new Error('UI constructor must have 1 record argument'); + + const fields = value.args[0].fields; + + switch (value.name) { + case 'Rect': { + const w = fields.w; + const h = fields.h; + const color = fields.color; + + if (w.kind !== 'int' || h.kind !== 'int' || color.kind !== 'string') + throw new Error('Invalid Rect fields'); + + return { kind: 'rect', w: w.value, h: h.value, color: color.value }; + } + + case 'Text': { + const x = fields.x; + const y = fields.y; + const content = fields.content; + + if (content.kind !== 'string' || x.kind !== 'int' || y.kind !== 'int') + throw new Error('Invalid Text fields'); + + return { kind: 'text', x: x.value, y: y.value, content: content.value }; + } + + case 'Column': { + const children = fields.children; + const gap = fields.gap; + + if (children.kind !== 'list' || gap.kind !== 'int') + throw new Error('Invalid Column fields'); + + return { kind: 'column', gap: gap.value, children: children.elements.map(valueToUI) }; + } + + case 'Row': { + const children = fields.children; + const gap = fields.gap; + + if (children.kind !== 'list' || gap.kind !== 'int') + throw new Error('Invalid Row fields'); + + return { kind: 'row', gap: gap.value, children: children.elements.map(valueToUI) }; + } + + case 'Clickable': { + const child = fields.child; + const event = fields.event; + + if (event.kind !== 'string') + throw new Error('Invalid Clickable fields'); + + return { kind: 'clickable', event: event.value, child: valueToUI(child) }; + } + + case 'Padding': { + const child = fields.child; + const amount = fields.amount; + + if (amount.kind !== 'int') + throw new Error('Invalid Padding fields'); + + return { kind: 'padding', amount: amount.value, child: valueToUI(child) }; + } + + default: + throw new Error(`Unknown UI constructor: ${value.name}`); + } +}