You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

282 lines
8.9 KiB
TypeScript

// 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<T extends { x: number, y: number, width: number, height: number }>(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 };
}