import type { UIValue, Value } from './types'; type ClickRegion = { x: number; y: number; width: number; height: number; event: Value; }; type TextInputRegion = { x: number; y: number; width: number; height: number; inputConstructor: Value; submitConstructor: Value; } let clickRegions: ClickRegion[] = []; let textInputRegions: TextInputRegion[] = []; let focusedInput: Value | null = null; let focusedInputSubmit: Value | null = null; export function render(ui: UIValue, canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (ctx) { const dpr = window.devicePixelRatio || 1; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); clickRegions = []; textInputRegions = []; renderUI(ui, ctx, 0, 0); } } function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: number) { switch (ui.kind) { case 'rect': { ctx.fillStyle = ui.color; if (ui.radius && ui.radius > 0) { const r = Math.min(ui.radius, ui.w / 2, ui.h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + ui.w - r, y); ctx.arcTo(x + ui.w, y, x + ui.w, y + r, r); ctx.lineTo(x + ui.w, y + ui.h - r); ctx.arcTo(x + ui.w, y + ui.h, x + ui.w - r, y + ui.h, r); ctx.lineTo(x + r, y + ui.h); ctx.arcTo(x, y + ui.h, x, y + ui.h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); ctx.fill(); } else { ctx.fillRect(x, y, ui.w, ui.h); } break; } case 'text': ctx.fillStyle = 'black'; ctx.font = '16px "SF Mono", "Monaco", "Menlo", "Consolas", "Courier New", monospace'; ctx.fillText(ui.content, x + ui.x, y + ui.y); break; case 'row': { let offsetX = 0; for (const child of ui.children) { renderUI(child, ctx, x + offsetX, y); offsetX += measure(child).width + ui.gap; } break; } case 'column': { let offsetY = 0; for (const child of ui.children) { renderUI(child, ctx, x, y + offsetY); offsetY += measure(child).height + ui.gap; } break; } 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; } case 'padding': 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; renderUI(ui.child, ctx, x, y); ctx.globalAlpha = previousAlpha; break; } case 'clip': { ctx.save(); ctx.beginPath(); ctx.rect(x, y, ui.w, ui.h); ctx.clip(); renderUI(ui.child, ctx, x, y); ctx.restore(); 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 "SF Mono", "Monaco", "Menlo", "Consolas", "Courier New", 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, inputConstructor: ui.onInput, submitConstructor: ui.onSubmit }); if (ui.focused && focusedInput && (ui.onInput as any).name === (focusedInput as any).name) { 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; for (const child of ui.children) { const size = measure(child); totalWidth += size.width; maxHeight = Math.max(maxHeight, size.height); } totalWidth += ui.gap * (ui.children.length - 1); return { width: totalWidth, height: maxHeight }; } case 'column': { let totalHeight = 0; let maxWidth = 0; for (const child of ui.children) { const size = measure(child); totalHeight += size.height; maxWidth = Math.max(maxWidth, size.width); } totalHeight += ui.gap * (ui.children.length - 1); return { width: maxWidth, height: totalHeight }; } case 'clickable': return measure(ui.child); case 'opacity': return measure(ui.child); case 'clip': return { width: ui.w, height: ui.h }; case 'padding': { const childSize = measure(ui.child); return { width: childSize.width + ui.amount * 2, height: childSize.height + ui.amount * 2, } } case 'positioned': return measure(ui.child); 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 }; } } export function hitTest(x: number, y: number): Value | null { for (const region of clickRegions) { if (x >= region.x && x < region.x + region.width && y >= region.y && y < region.y + region.height) { return region.event; } } 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.inputConstructor; focusedInputSubmit = region.submitConstructor; return true; } } focusedInput = null; focusedInputSubmit = null; return false; } export function getFocusedInput(): Value | null { return focusedInput; } export function handleKeyboard(key: string): Value | null { if (!focusedInput) return null; if (key === 'Enter') { if (!focusedInputSubmit) return null; return { kind: 'constructor', name: (focusedInputSubmit as any).name, args: [{ kind: 'string', value: currentInputValue }] }; } if (key === 'Backspace') { const newValue = currentInputValue.slice(0, -1); currentInputValue = newValue; return { kind: 'constructor', name: (focusedInput as any).name, args: [{ kind: 'string', value: newValue }] }; } // Character if (key.length === 1) { const newValue = currentInputValue + key; currentInputValue = newValue; return { kind: 'constructor', name: (focusedInput as any).name, args: [{ kind: 'string', value: newValue }] }; } return null; }