We have UI! kind of

master
Dustin Swan 5 days ago
parent 52647a9ce1
commit 5b40e9d298
Signed by: dustinswan
GPG Key ID: 30D46587E2100467

@ -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 }

@ -1,30 +1,30 @@
import type { UIValue } from './types'; import { evaluate } from './interpreter'
import { render } from './ui'; 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'); const canvas = document.createElement('canvas');
canvas.width = 800; canvas.width = 800;
canvas.height = 600; canvas.height = 600;
document.body.appendChild(canvas); document.body.appendChild(canvas);
const ui: UIValue = { const tokens = tokenize(cgCode);
kind: 'column', const parser = new Parser(tokens);
gap: 10, const ast = parser.parse();
children: [ console.log(ast);
{ 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'
}
}
}
]
}
]
};
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);

@ -281,25 +281,31 @@ export class Parser {
const field = (fieldToken as { value: string }).value; const field = (fieldToken as { value: string }).value;
expr = { kind: 'record-access', record: expr, field }; expr = { kind: 'record-access', record: expr, field };
} else if (this.current().kind === 'open-brace') { } else if (this.current().kind === 'open-brace') {
// Record update if (expr.kind === 'constructor') {
this.advance(); // Constructor application
const updates: { [key: string]: AST } = {}; const record = this.parsePrimary();
let first = true; expr = { kind: 'apply', func: expr, args: [record] };
} else {
while (this.current().kind !== 'close-brace') { // Record update
if (!first) { this.advance();
this.expect('comma'); 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'); this.expect('close-brace');
const key = (keyToken as { value: string }).value; expr = { kind: 'record-update', record: expr, updates }
this.expect('equals');
updates[key] = this.parseExpression();
} }
this.expect('close-brace');
expr = { kind: 'record-update', record: expr, updates }
} else { } else {
break; break;
} }

@ -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();
}

@ -47,4 +47,4 @@ export type UIValue =
| { kind: 'clickable', child: UIValue, event: string } | { kind: 'clickable', child: UIValue, event: string }
| { kind: 'padding', child: UIValue, amount: number } | { 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;

@ -1,9 +1,20 @@
import type { UIValue } from './types'; 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) { export function render(ui: UIValue, canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (ctx) { if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
clickRegions = [];
renderUI(ui, ctx, 0, 0); renderUI(ui, ctx, 0, 0);
} }
} }
@ -40,6 +51,8 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb
} }
case 'clickable': { 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); renderUI(ui.child, ctx, x, y);
break; 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;
} }

@ -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}`);
}
}
Loading…
Cancel
Save