// import type { UIValue, Value } from './types'; export type UIValue = | { kind: 'rect', w: number, h: number, color?: string, strokeColor?: string, strokeWidth?: number, radius?: number } | { kind: 'text', content: string, color?: string } | { kind: 'row', children: UIValue[], gap: number } | { kind: 'column', children: UIValue[], gap: number } | { kind: 'padding', child: UIValue, amount: number } | { kind: 'positioned', x: number, y: number, child: UIValue } | { kind: 'opacity', child: UIValue, opacity: number } | { kind: 'clip', child: UIValue, w: number, h: number } | { kind: 'stack', children: UIValue[] } | { kind: 'clickable', child: UIValue, onClick: any } | { kind: 'scrollable', child: UIValue, w: number, h: number, scrollX: number, scrollY: number, onScroll: any } | { kind: 'stateful', key: string, focusable: boolean, init: any, update: any, view: any } type ClickRegion = { x: number; y: number; width: number; height: number; onClick: any; }; let clickRegions: ClickRegion[] = []; type ScrollRegion = { x: number; y: number; width: number; height: number; onScroll: any; }; let scrollRegions: ScrollRegion[] = []; 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 = []; scrollRegions = []; renderUI(ui, ctx, 0, 0); } } function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: number) { switch (ui.kind) { case 'rect': { ctx.fillStyle = ui.color || 'transparent'; if (ui.radius && ui.radius > 0) { const r = Math.min(ui.radius, ui.w / 2, ui.h / 2); // const inset = ui.strokeWidth ? ui.strokeWidth / 2 : 0; TODO 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(); if (ui.strokeColor && ui.strokeWidth) { ctx.strokeStyle = ui.strokeColor; ctx.lineWidth = ui.strokeWidth; ctx.stroke(); } } else { ctx.fillRect(x, y, ui.w, ui.h); if (ui.strokeColor && ui.strokeWidth) { ctx.strokeStyle = ui.strokeColor; ctx.lineWidth = ui.strokeWidth; const inset = ui.strokeWidth / 2; ctx.strokeRect(x + inset, y + inset, ui.w - ui.strokeWidth, ui.h - ui.strokeWidth); } } break; } case 'text': ctx.fillStyle = ui.color || 'black'; ctx.font = '16px "SF Mono", "Monaco", "Menlo", "Consolas", "Courier New", monospace'; ctx.textBaseline = 'top'; ctx.fillText(ui.content, x, y); break; case 'row': { let offsetX = 0; for (const child of ui.children) { // const size = measure(child); renderUI(child, ctx, x + offsetX, y); offsetX += measure(child).width + (ui.gap || 0); } break; } case 'column': { let offsetY = 0; for (const child of ui.children) { renderUI(child, ctx, x, y + offsetY); offsetY += measure(child).height + (ui.gap || 0); } break; } case 'clickable': { const size = measure(ui.child); clickRegions.push({ x, y, width: size.width, height: size.height, onClick: ui.onClick }) renderUI(ui.child, ctx, x, y); break; } case 'scrollable': { ctx.save(); ctx.beginPath(); ctx.rect(x, y, ui.w, ui.h); ctx.clip(); scrollRegions.push({ x, y, width: ui.w, height: ui.h, onScroll: ui.onScroll }) renderUI(ui.child, ctx, x - ui.scrollX, y - ui.scrollY); // console.log("scrollable onScroll after render:", ui.onScroll, ui.w, ui.h) ctx.restore(); 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; } } } export function measure(ui: UIValue): { width: number, height: number } { const result = _measure(ui); if (isNaN(result.width) || isNaN(result.height)) { console.warn('NaN from:', ui.kind, ui); } return result; } export function _measure(ui: UIValue): { width: number, height: number } { switch (ui.kind) { case 'rect': return { width: ui.w || 0, height: ui.h || 0 }; 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 || 0) * (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 || 0) * (ui.children.length - 1); return { width: maxWidth, height: totalHeight }; } case 'clickable': return measure(ui.child); case 'scrollable': return { width: ui.w, height: ui.h }; 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': { const childSize = measure(ui.child); return { width: ui.x + childSize.width, height: ui.y + childSize.height, }; } 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 'stateful': throw new Error('Stateful components cannot be measured'); default: return { width: 0, height: 0 }; } } function findRegion(regions: T[], x: number, y: number ): T | null { for (let i = regions.length - 1; i >= 0; i--) { const r = regions[i]; if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) { return r; } } return null; } export function hitTest(x: number, y: number): { onClick: any, relativeX: number, relativeY: number } | null { const region = findRegion(clickRegions, x, y); if (!region) return null; return { onClick: region.onClick, relativeX: x - region.x, relativeY: y - region.y }; } export function scrollHitTest(x: number, y: number): { onScroll: any } | null { const region = findRegion(scrollRegions, x, y); if (!region) return null; return { onScroll: region.onScroll }; }