diff --git a/src/main.ts b/src/main.ts index 2b3d904..fe60fff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { builtins } from './builtins'; import counterApp from './counter.cg?raw'; import stdlibCode from './stdlib.cg?raw'; +import textInputCode from './textinput-test.cg?raw'; import testCode from './test.cg?raw'; const canvas = document.createElement('canvas'); @@ -14,7 +15,7 @@ canvas.width = 800; canvas.height = 600; document.body.appendChild(canvas); -const cgCode = stdlibCode + '\n' + testCode; +const cgCode = stdlibCode + '\n' + textInputCode; const tokens = tokenize(cgCode); const parser = new Parser(tokens); @@ -22,10 +23,9 @@ const ast = parser.parse(); console.log(ast); const env: Env = new Map(Object.entries(builtins)); -const res = evaluate(ast, env); -console.log(res); +// const res = evaluate(ast, env); +// console.log(res); -/* const appRecord = evaluate(ast, env); console.log(appRecord); @@ -38,4 +38,3 @@ const update = appRecord.fields.update; const view = appRecord.fields.view; runApp({ init, update, view }, canvas); -*/ diff --git a/src/runtime.ts b/src/runtime.ts index eb5c664..cfde57d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,6 @@ import type { Value } from './types'; import { valueToUI } from './valueToUI'; -import { render, hitTest } from './ui'; +import { render, hitTest, hitTestTextInput, handleKeyboard } from './ui'; import { evaluate } from './interpreter'; export type App = { @@ -24,14 +24,13 @@ export function runApp(app: App, canvas: HTMLCanvasElement) { render(ui, canvas); } - function handleEvent(eventName: string) { + function handleEvent(event: Value) { 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); @@ -46,11 +45,39 @@ export function runApp(app: App, canvas: HTMLCanvasElement) { const x = e.clientX - rect.left; const y = e.clientY - rect.top; + // Text Inputs + const hitTextInput = hitTestTextInput(x, y); + if (hitTextInput) { + rerender(); + return; + } + const eventName = hitTest(x, y); 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(); } diff --git a/src/textinput-test.cg b/src/textinput-test.cg new file mode 100644 index 0000000..8cec12a --- /dev/null +++ b/src/textinput-test.cg @@ -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 } diff --git a/src/types.ts b/src/types.ts index c2d3a99..cb8d465 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,7 +44,6 @@ export type NativeFunction = { name: string arity: number fn: (...args: Value[]) => Value - } export type UIValue = @@ -54,5 +53,7 @@ export type UIValue = | { kind: 'column', children: UIValue[], gap: number } | { kind: 'clickable', child: UIValue, event: string } | { 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; diff --git a/src/ui.ts b/src/ui.ts index 860d904..41c189c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -8,13 +8,26 @@ type ClickRegion = { event: string; }; +type TextInputRegion = { + x: number; + y: number; + width: number; + height: number; + inputId: string; + submitId: string; +} + let clickRegions: ClickRegion[] = []; +let textInputRegions: TextInputRegion[] = []; +let focusedInput: string | null = null; +let focusedInputSubmit: string | null = null; export function render(ui: UIValue, canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); clickRegions = []; + textInputRegions = []; 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); break; } + case 'padding': renderUI(ui.child, ctx, x + ui.amount, y + ui.amount); 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 } { switch (ui.kind) { case 'rect': return { width: ui.w, height: ui.h }; + case 'text': return { width: ui.content.length * 10, height: 20 }; // TODO + case 'row': { let totalWidth = 0; let maxHeight = 0; @@ -77,6 +132,7 @@ function measure(ui: UIValue): { width: number, height: number } { totalWidth += ui.gap * (ui.children.length - 1); return { width: totalWidth, height: maxHeight }; } + case 'column': { let totalHeight = 0; let maxWidth = 0; @@ -88,8 +144,10 @@ function measure(ui: UIValue): { width: number, height: number } { totalHeight += ui.gap * (ui.children.length - 1); return { width: maxWidth, height: totalHeight }; } + case 'clickable': return measure(ui.child); + case 'padding': { const childSize = measure(ui.child); return { @@ -97,6 +155,21 @@ function measure(ui: UIValue): { width: number, height: number } { 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 }; @@ -111,3 +184,55 @@ export function hitTest(x: number, y: number): string | 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; +} diff --git a/src/valueToUI.ts b/src/valueToUI.ts index 2bf0e20..8ea799a 100644 --- a/src/valueToUI.ts +++ b/src/valueToUI.ts @@ -1,7 +1,6 @@ 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'); } @@ -74,6 +73,39 @@ export function valueToUI(value: Value): UIValue { 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: throw new Error(`Unknown UI constructor: ${value.name}`); }