From bc186d658cd195c80b90b4360c36083c62601dd9 Mon Sep 17 00:00:00 2001 From: Dustin Swan Date: Tue, 3 Feb 2026 18:02:19 -0700 Subject: [PATCH] creating a CG text input --- src/builtins.ts | 30 ++++++++++--- src/runtime.ts | 26 ++++++++---- src/textinput-test.cg | 97 ++++++++++++++++++++++++++++++------------- src/types.ts | 1 + src/ui.ts | 9 +++- src/valueToUI.ts | 9 ++++ 6 files changed, 128 insertions(+), 44 deletions(-) diff --git a/src/builtins.ts b/src/builtins.ts index 7294c65..7caf2dc 100644 --- a/src/builtins.ts +++ b/src/builtins.ts @@ -1,5 +1,11 @@ import type { Value, NativeFunction } from './types' +const measureCanvas = document.createElement('canvas'); +const measureCtx = measureCanvas.getContext('2d')!; +if (!measureCtx) + throw new Error('Failed to create canvas'); +measureCtx.font = '16px "Courier New", monospace'; + function expectInt(v: Value, name: string): number { if (v.kind !== 'int') throw new Error(`${name} expects int, got ${v.kind}`); @@ -18,11 +24,11 @@ function expectNumber(v: Value, name: string): number { return v.value; } -// function expectString(v: Value, name: string): string { -// if (v.kind !== 'string') -// throw new Error(`${name} expects string, got ${v.kind}`); -// return v.value; -// } +function expectString(v: Value, name: string): string { + if (v.kind !== 'string') + throw new Error(`${name} expects string, got ${v.kind}`); + return v.value; +} // function expectList(v: Value, name: string): Value[] { // if (v.kind !== 'list') @@ -345,7 +351,7 @@ export const builtins: { [name: string]: NativeFunction } = { return { kind: 'float', value: val.value }; if (val.kind === 'string') { - const parsed = parseFloat(val.value, 10); + const parsed = parseFloat(val.value); if (isNaN(parsed)) throw new Error(`float: cannot parse "${val.value}"`); @@ -444,4 +450,16 @@ export const builtins: { [name: string]: NativeFunction } = { return { kind: 'int', value: result }; } }, + + 'measureText': { + kind: 'native', + name: 'measureText', + arity: 1, + fn: (text) => { + const str = expectString(text, 'measureText'); + // TODO + const metrics = measureCtx.measureText(str); + return { kind: 'float', value: metrics.width }; + } + } } diff --git a/src/runtime.ts b/src/runtime.ts index d8e2c3c..ca148ef 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, hitTestTextInput, handleKeyboard } from './ui'; +import { render, hitTest, hitTestTextInput } from './ui'; import { evaluate } from './interpreter'; export type App = { @@ -81,15 +81,27 @@ export function runApp(app: App, canvas: HTMLCanvasElement) { }); window.addEventListener('keydown', (e) => { - const event = handleKeyboard(e.key); + let event: Value | null = null; - if (event) { - handleEvent(event); - e.preventDefault(); + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + event = { + kind: 'constructor', + name: 'Char', + args: [{ kind: 'string', value: e.key }] + } + } else { + event = { + kind: 'constructor', + name: e.key, + args: [] + } } - }) - window.addEventListener('resize', (e) => { + handleEvent(event); + e.preventDefault(); + }); + + window.addEventListener('resize', () => { setupCanvas(); rerender(); }) diff --git a/src/textinput-test.cg b/src/textinput-test.cg index 4072616..4fc642c 100644 --- a/src/textinput-test.cg +++ b/src/textinput-test.cg @@ -1,41 +1,78 @@ -init = { text = "" }; +# Helpers + +insertChar = text pos char \ + before = slice text 0 pos; + after = slice text pos (len text); + before & char & after; + +deleteChar = text pos \ + (pos == 0 + | True \ text + | False \ + (before = slice text 0 (pos - 1); + after = slice text pos (len text); + before & after)); + +# test app + +init = { + text = "hello world", + cursorPos = 5 +}; update = state event \ event - | UpdateText newText \ state.{ text = newText } - | Submit _ \ state.{ text = "" } - | Go \ state.{ text = "" }; + | ArrowLeft \ state.{ cursorPos = max 0 (state.cursorPos - 1) } + | ArrowRight \ state.{ cursorPos = min (len state.text) (state.cursorPos + 1) } + | Backspace \ { + text = deleteChar state.text state.cursorPos, + cursorPos = max 0 (state.cursorPos - 1) + } + | Char c \ { + text = insertChar state.text state.cursorPos c, + cursorPos = state.cursorPos + 1 + } + | _ \ state; view = state viewport \ - Padding { - amount = 20, - child = Column { - gap = 20, + # charWidth = 9.65; + # cursorX = state.cursorPos * charWidth; + textBeforeCursor = slice state.text 0 state.cursorPos; + cursorX = measureText textBeforeCursor; + + Column { + gap = 20, + children = [ + Text { + content = "Text: " & state.text & " | Cursor: " & str(state.cursorPos), + x = 0, + y = 20 + }, + + # Text Input Component + Stack { children = [ - Text { - content = "window: " & str(viewport.width) & " x " & str(viewport.height), - x = 0, - y = 20 - }, - Text { content = "You typed: " & state.text, x = 0, y = 20 }, - Stack { - children = [ - Rect { w = 300, h = 40, color = "blue", radius = 2 }, - TextInput { - value = state.text, - placeholder = "Type something...", - x = 5, - y = 5, - w = 290, - h = 30, - focused = True, - onInput = UpdateText, - onSubmit = Submit - } - ] + Rect { w = 300, h = 40, color = "white", radius = 4 }, + + # Text content + Positioned { + x = 8, + y = 8, + child = Text { content = state.text, x = 0, y = 17 } }, - button { label = "Go", event = Go, theme = theme } + + # Cursor + Positioned { + x = 8 + cursorX, + y = 8, + child = Rect { + w = 2, + h = 24, + color = "black" + } + } ] } + ] }; { init = init, update = update, view = view } diff --git a/src/types.ts b/src/types.ts index 8ae84a9..ce7d205 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export type UIValue = | { kind: 'column', children: UIValue[], gap: number } | { kind: 'clickable', child: UIValue, event: Value } | { kind: 'padding', child: UIValue, amount: number } + | { kind: 'positioned', x: number, y: number, child: UIValue } | { kind: 'opacity', child: UIValue, opacity: number } | { kind: 'stack', children: UIValue[] } | { kind: 'text-input', value: string, placeholder: string, x: number, y: number, w: number, h: number, focused: boolean, onInput: Value, onSubmit: Value } diff --git a/src/ui.ts b/src/ui.ts index 5880778..e513896 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -65,7 +65,7 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb case 'text': ctx.fillStyle = 'black'; - ctx.font = '16px monospace'; + ctx.font = '16px "Courier New", monospace'; ctx.fillText(ui.content, x + ui.x, y + ui.y); break; @@ -98,6 +98,10 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb renderUI(ui.child, ctx, x + ui.amount, y + ui.amount); break; + case 'positioned': + renderUI(ui.child, ctx, x + ui.x, y + ui.y); + break; + case 'opacity': { const previousAlpha = ctx.globalAlpha; ctx.globalAlpha = previousAlpha * ui.opacity; @@ -188,6 +192,9 @@ function measure(ui: UIValue): { width: number, height: number } { } } + case 'positioned': + return measure(ui.child); + case 'stack': { let maxWidth = 0; let maxHeight = 0; diff --git a/src/valueToUI.ts b/src/valueToUI.ts index f9a56a6..3296ec8 100644 --- a/src/valueToUI.ts +++ b/src/valueToUI.ts @@ -77,6 +77,15 @@ export function valueToUI(value: Value): UIValue { return { kind: 'padding', amount: amount.value, child: valueToUI(child) }; } + case 'Positioned': { + const { x, y, child } = fields; + + if (x.kind !== 'int' || y.kind !== 'int') + throw new Error('Invalid Positioned fields'); + + return { kind: 'positioned', x: x.value, y: y.value, child: valueToUI(child) }; + } + case 'Opacity': { const { child, opacity } = fields;