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.
297 lines
8.4 KiB
TypeScript
297 lines
8.4 KiB
TypeScript
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;
|
|
}
|