Adding Stack and TextInput to the UI
This commit is contained in:
parent
50bb15e974
commit
223eea72e3
6 changed files with 226 additions and 11 deletions
|
|
@ -7,6 +7,7 @@ import { builtins } from './builtins';
|
||||||
|
|
||||||
import counterApp from './counter.cg?raw';
|
import counterApp from './counter.cg?raw';
|
||||||
import stdlibCode from './stdlib.cg?raw';
|
import stdlibCode from './stdlib.cg?raw';
|
||||||
|
import textInputCode from './textinput-test.cg?raw';
|
||||||
import testCode from './test.cg?raw';
|
import testCode from './test.cg?raw';
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
|
|
@ -14,7 +15,7 @@ canvas.width = 800;
|
||||||
canvas.height = 600;
|
canvas.height = 600;
|
||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
|
|
||||||
const cgCode = stdlibCode + '\n' + testCode;
|
const cgCode = stdlibCode + '\n' + textInputCode;
|
||||||
|
|
||||||
const tokens = tokenize(cgCode);
|
const tokens = tokenize(cgCode);
|
||||||
const parser = new Parser(tokens);
|
const parser = new Parser(tokens);
|
||||||
|
|
@ -22,10 +23,9 @@ const ast = parser.parse();
|
||||||
console.log(ast);
|
console.log(ast);
|
||||||
|
|
||||||
const env: Env = new Map(Object.entries(builtins));
|
const env: Env = new Map(Object.entries(builtins));
|
||||||
const res = evaluate(ast, env);
|
// const res = evaluate(ast, env);
|
||||||
console.log(res);
|
// console.log(res);
|
||||||
|
|
||||||
/*
|
|
||||||
const appRecord = evaluate(ast, env);
|
const appRecord = evaluate(ast, env);
|
||||||
|
|
||||||
console.log(appRecord);
|
console.log(appRecord);
|
||||||
|
|
@ -38,4 +38,3 @@ const update = appRecord.fields.update;
|
||||||
const view = appRecord.fields.view;
|
const view = appRecord.fields.view;
|
||||||
|
|
||||||
runApp({ init, update, view }, canvas);
|
runApp({ init, update, view }, canvas);
|
||||||
*/
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Value } from './types';
|
import type { Value } from './types';
|
||||||
import { valueToUI } from './valueToUI';
|
import { valueToUI } from './valueToUI';
|
||||||
import { render, hitTest } from './ui';
|
import { render, hitTest, hitTestTextInput, handleKeyboard } from './ui';
|
||||||
import { evaluate } from './interpreter';
|
import { evaluate } from './interpreter';
|
||||||
|
|
||||||
export type App = {
|
export type App = {
|
||||||
|
|
@ -24,14 +24,13 @@ export function runApp(app: App, canvas: HTMLCanvasElement) {
|
||||||
render(ui, canvas);
|
render(ui, canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(eventName: string) {
|
function handleEvent(event: Value) {
|
||||||
if (app.update.kind !== 'closure')
|
if (app.update.kind !== 'closure')
|
||||||
throw new Error('update must be a function');
|
throw new Error('update must be a function');
|
||||||
|
|
||||||
if (app.update.params.length !== 2)
|
if (app.update.params.length !== 2)
|
||||||
throw new Error('update must have 2 parameters');
|
throw new Error('update must have 2 parameters');
|
||||||
|
|
||||||
const event: Value = { kind: 'string', value: eventName };
|
|
||||||
const callEnv = new Map(app.update.env);
|
const callEnv = new Map(app.update.env);
|
||||||
callEnv.set(app.update.params[0], state);
|
callEnv.set(app.update.params[0], state);
|
||||||
callEnv.set(app.update.params[1], event);
|
callEnv.set(app.update.params[1], event);
|
||||||
|
|
@ -46,11 +45,39 @@ export function runApp(app: App, canvas: HTMLCanvasElement) {
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Text Inputs
|
||||||
|
const hitTextInput = hitTestTextInput(x, y);
|
||||||
|
if (hitTextInput) {
|
||||||
|
rerender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const eventName = hitTest(x, y);
|
const eventName = hitTest(x, y);
|
||||||
if (eventName) {
|
if (eventName) {
|
||||||
handleEvent(eventName);
|
const event: Value = {
|
||||||
|
kind: 'constructor',
|
||||||
|
name: eventName,
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
handleEvent(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
const result = handleKeyboard(e.key);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const event: Value = {
|
||||||
|
kind: 'constructor',
|
||||||
|
name: result.event,
|
||||||
|
args: [{ kind: 'string', value: result.value }]
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
rerender();
|
rerender();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/textinput-test.cg
Normal file
31
src/textinput-test.cg
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
init = { text = "" };
|
||||||
|
|
||||||
|
update = state event \ event
|
||||||
|
| UpdateText newText \ state { text = newText }
|
||||||
|
| Submit _ \ state { text = "" };
|
||||||
|
|
||||||
|
view = state \
|
||||||
|
Column {
|
||||||
|
gap = 20,
|
||||||
|
children = [
|
||||||
|
Text { content = "You typed: " & state.text, x = 0, y = 20 },
|
||||||
|
Stack {
|
||||||
|
children = [
|
||||||
|
Rect { w = 300, h = 40, color = "white" },
|
||||||
|
TextInput {
|
||||||
|
value = state.text,
|
||||||
|
placeholder = "Type something...",
|
||||||
|
x = 5,
|
||||||
|
y = 5,
|
||||||
|
w = 290,
|
||||||
|
h = 30,
|
||||||
|
focused = True,
|
||||||
|
onInput = "UpdateText",
|
||||||
|
onSubmit = "Submit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
{ init = init, update = update, view = view }
|
||||||
|
|
@ -44,7 +44,6 @@ export type NativeFunction = {
|
||||||
name: string
|
name: string
|
||||||
arity: number
|
arity: number
|
||||||
fn: (...args: Value[]) => Value
|
fn: (...args: Value[]) => Value
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UIValue =
|
export type UIValue =
|
||||||
|
|
@ -54,5 +53,7 @@ export type UIValue =
|
||||||
| { kind: 'column', children: UIValue[], gap: number }
|
| { kind: 'column', children: UIValue[], gap: number }
|
||||||
| { kind: 'clickable', child: UIValue, event: string }
|
| { kind: 'clickable', child: UIValue, event: string }
|
||||||
| { kind: 'padding', child: UIValue, amount: number }
|
| { kind: 'padding', child: UIValue, amount: number }
|
||||||
|
| { kind: 'stack', children: UIValue[] }
|
||||||
|
| { kind: 'text-input', value: string, placeholder: string, x: number, y: number, w: number, h: number, focused: boolean, onInput: string, onSubmit: string }
|
||||||
|
|
||||||
export type Value = IntValue | FloatValue | StringValue | Closure | ListValue | RecordValue | ConstructorValue | NativeFunction;
|
export type Value = IntValue | FloatValue | StringValue | Closure | ListValue | RecordValue | ConstructorValue | NativeFunction;
|
||||||
|
|
|
||||||
125
src/ui.ts
125
src/ui.ts
|
|
@ -8,13 +8,26 @@ type ClickRegion = {
|
||||||
event: string;
|
event: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TextInputRegion = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
inputId: string;
|
||||||
|
submitId: string;
|
||||||
|
}
|
||||||
|
|
||||||
let clickRegions: ClickRegion[] = [];
|
let clickRegions: ClickRegion[] = [];
|
||||||
|
let textInputRegions: TextInputRegion[] = [];
|
||||||
|
let focusedInput: string | null = null;
|
||||||
|
let focusedInputSubmit: string | null = null;
|
||||||
|
|
||||||
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 = [];
|
clickRegions = [];
|
||||||
|
textInputRegions = [];
|
||||||
renderUI(ui, ctx, 0, 0);
|
renderUI(ui, ctx, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,16 +69,58 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb
|
||||||
renderUI(ui.child, ctx, x, y);
|
renderUI(ui.child, ctx, x, y);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'padding':
|
case 'padding':
|
||||||
renderUI(ui.child, ctx, x + ui.amount, y + ui.amount);
|
renderUI(ui.child, ctx, x + ui.amount, y + ui.amount);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'stack': {
|
||||||
|
for (const child of ui.children) {
|
||||||
|
renderUI(child, ctx, x, y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'text-input': {
|
||||||
|
ctx.fillStyle = ui.value ? '#000000' : '#999999';
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.fillText(
|
||||||
|
ui.value || ui.placeholder,
|
||||||
|
x + ui.x + 8,
|
||||||
|
y + ui.y + ui.h / 2 + 6
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw cursor
|
||||||
|
if (ui.focused) {
|
||||||
|
const textWidth = ctx.measureText(ui.value).width;
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillRect(x + ui.x + 8 + textWidth, y + ui.y + 8, 2, ui.h - 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
textInputRegions.push({
|
||||||
|
x: x + ui.x,
|
||||||
|
y: y + ui.y,
|
||||||
|
width: ui.w,
|
||||||
|
height: ui.h,
|
||||||
|
inputId: ui.onInput,
|
||||||
|
submitId: ui.onSubmit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ui.focused && ui.onInput === focusedInput) {
|
||||||
|
currentInputValue = ui.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function measure(ui: UIValue): { width: number, height: number } {
|
function measure(ui: UIValue): { width: number, height: number } {
|
||||||
switch (ui.kind) {
|
switch (ui.kind) {
|
||||||
case 'rect': return { width: ui.w, height: ui.h };
|
case 'rect': return { width: ui.w, height: ui.h };
|
||||||
|
|
||||||
case 'text': return { width: ui.content.length * 10, height: 20 }; // TODO
|
case 'text': return { width: ui.content.length * 10, height: 20 }; // TODO
|
||||||
|
|
||||||
case 'row': {
|
case 'row': {
|
||||||
let totalWidth = 0;
|
let totalWidth = 0;
|
||||||
let maxHeight = 0;
|
let maxHeight = 0;
|
||||||
|
|
@ -77,6 +132,7 @@ function measure(ui: UIValue): { width: number, height: number } {
|
||||||
totalWidth += ui.gap * (ui.children.length - 1);
|
totalWidth += ui.gap * (ui.children.length - 1);
|
||||||
return { width: totalWidth, height: maxHeight };
|
return { width: totalWidth, height: maxHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'column': {
|
case 'column': {
|
||||||
let totalHeight = 0;
|
let totalHeight = 0;
|
||||||
let maxWidth = 0;
|
let maxWidth = 0;
|
||||||
|
|
@ -88,8 +144,10 @@ function measure(ui: UIValue): { width: number, height: number } {
|
||||||
totalHeight += ui.gap * (ui.children.length - 1);
|
totalHeight += ui.gap * (ui.children.length - 1);
|
||||||
return { width: maxWidth, height: totalHeight };
|
return { width: maxWidth, height: totalHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'clickable':
|
case 'clickable':
|
||||||
return measure(ui.child);
|
return measure(ui.child);
|
||||||
|
|
||||||
case 'padding': {
|
case 'padding': {
|
||||||
const childSize = measure(ui.child);
|
const childSize = measure(ui.child);
|
||||||
return {
|
return {
|
||||||
|
|
@ -97,6 +155,21 @@ function measure(ui: UIValue): { width: number, height: number } {
|
||||||
height: childSize.height + ui.amount * 2,
|
height: childSize.height + ui.amount * 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'stack': {
|
||||||
|
let maxWidth = 0;
|
||||||
|
let maxHeight = 0;
|
||||||
|
for (const child of ui.children) {
|
||||||
|
const size = measure(child);
|
||||||
|
maxWidth = Math.max(maxWidth, size.width);
|
||||||
|
maxHeight = Math.max(maxHeight, size.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: maxWidth, height: maxHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'text-input':
|
||||||
|
return { width: ui.w, height: ui.h };
|
||||||
}
|
}
|
||||||
|
|
||||||
// return { width: 0, height: 0 };
|
// return { width: 0, height: 0 };
|
||||||
|
|
@ -111,3 +184,55 @@ export function hitTest(x: number, y: number): string | null {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentInputValue: string = '';
|
||||||
|
|
||||||
|
export function hitTestTextInput(x: number, y: number): boolean {
|
||||||
|
for (const region of textInputRegions) {
|
||||||
|
if (x >= region.x && x < region.x + region.width &&
|
||||||
|
y >= region.y && y < region.y + region.height) {
|
||||||
|
focusedInput = region.inputId;
|
||||||
|
focusedInputSubmit = region.submitId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusedInput = null;
|
||||||
|
focusedInputSubmit = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFocusedInput(): string | null {
|
||||||
|
return focusedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeInputValue(inputId: string, value: string) {
|
||||||
|
if (inputId === focusedInput) {
|
||||||
|
currentInputValue = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleKeyboard(key: string): { event: string, value: string } | null {
|
||||||
|
if (!focusedInput) return null;
|
||||||
|
|
||||||
|
if (key === 'Enter') {
|
||||||
|
return focusedInputSubmit
|
||||||
|
? { event: focusedInputSubmit, value: currentInputValue }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Backspace') {
|
||||||
|
const newValue = currentInputValue.slice(0, -1);
|
||||||
|
currentInputValue = newValue;
|
||||||
|
return { event: focusedInput, value: newValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character
|
||||||
|
if (key.length === 1) {
|
||||||
|
const newValue = currentInputValue + key;
|
||||||
|
currentInputValue = newValue;
|
||||||
|
return { event: focusedInput, value: newValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Value, UIValue } from './types';
|
import type { Value, UIValue } from './types';
|
||||||
|
|
||||||
export function valueToUI(value: Value): UIValue {
|
export function valueToUI(value: Value): UIValue {
|
||||||
console.log("valueToUI", value);
|
|
||||||
if (value.kind !== 'constructor') {
|
if (value.kind !== 'constructor') {
|
||||||
throw new Error('UI value must be a constructor');
|
throw new Error('UI value must be a constructor');
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +73,39 @@ export function valueToUI(value: Value): UIValue {
|
||||||
return { kind: 'padding', amount: amount.value, child: valueToUI(child) };
|
return { kind: 'padding', amount: amount.value, child: valueToUI(child) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'Stack': {
|
||||||
|
const children = fields.children;
|
||||||
|
|
||||||
|
if (children.kind !== 'list')
|
||||||
|
throw new Error('Invalid Stack fields');
|
||||||
|
|
||||||
|
return { kind: 'stack', children: children.elements.map(valueToUI) };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TextInput': {
|
||||||
|
const { value, placeholder, x, y, w, h, focused, onInput, onSubmit } = fields;
|
||||||
|
|
||||||
|
if (value.kind !== 'string' || placeholder.kind !== 'string' ||
|
||||||
|
x.kind !== 'int' || y.kind !== 'int' || w.kind !== 'int' || h.kind !== 'int' ||
|
||||||
|
onInput.kind !== 'string' || onSubmit.kind !== 'string')
|
||||||
|
throw new Error('Invalid TextInput fields');
|
||||||
|
|
||||||
|
const isFocused = focused.kind === 'constructor' && focused.name === 'True';
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'text-input',
|
||||||
|
value: value.value,
|
||||||
|
placeholder: placeholder.value,
|
||||||
|
x: x.value,
|
||||||
|
y: y.value,
|
||||||
|
w: w.value,
|
||||||
|
h: h.value,
|
||||||
|
focused: isFocused,
|
||||||
|
onInput: onInput.value,
|
||||||
|
onSubmit: onSubmit.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown UI constructor: ${value.name}`);
|
throw new Error(`Unknown UI constructor: ${value.name}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue