We have UI! kind of
This commit is contained in:
parent
52647a9ce1
commit
5b40e9d298
7 changed files with 221 additions and 40 deletions
16
src/counter.cg
Normal file
16
src/counter.cg
Normal file
|
|
@ -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 }
|
||||
46
src/main.ts
46
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
56
src/runtime.ts
Normal file
56
src/runtime.ts
Normal file
|
|
@ -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: '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;
|
||||
|
|
|
|||
25
src/ui.ts
25
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;
|
||||
}
|
||||
|
|
|
|||
80
src/valueToUI.ts
Normal file
80
src/valueToUI.ts
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue