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.
277 lines
8.8 KiB
TypeScript
277 lines
8.8 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':
|
|
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 '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 };
|
|
}
|