pex-gui
Version:
GUI controls for PEX.
592 lines (512 loc) • 18.2 kB
JavaScript
import { toHex } from "pex-color";
import { utils } from "pex-math";
function rectSet4(a, x, y, w, h) {
a[0][0] = x;
a[0][1] = y;
a[1][0] = x + w;
a[1][1] = y + h;
return a;
}
function makePaletteImage(item, w, img) {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = (w * img.height) / img.width;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
item.options.paletteImage = canvas;
item.options.paletteImage.data = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
).data;
item.options.paletteImage.aspectRatio = canvas.height / canvas.width;
item.dirty = true;
}
class CanvasRenderer {
constructor({ width, height, pixelRatio = devicePixelRatio, theme }) {
this.pixelRatio = pixelRatio;
this.theme = theme;
this.canvas = document.createElement("canvas");
this.canvas.width = width * this.pixelRatio;
this.canvas.height = height * this.pixelRatio;
this.ctx = this.canvas.getContext("2d");
this.dirty = true;
}
draw(items) {
this.dirty = false;
const {
fontFamily,
fontSize,
capHeight,
leftOffset,
topOffset,
columnWidth,
tabHeight,
headerSize,
titleHeight,
itemHeight,
graphHeight,
padding,
textPadding,
} = this.theme;
const sliderHeight = 0.7 * itemHeight;
const buttonHeight = 1.2 * itemHeight;
const ctx = this.ctx;
ctx.save();
ctx.scale(this.pixelRatio, this.pixelRatio);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const fontCapOffset = capHeight * fontSize;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = "middle";
let dx = leftOffset;
let dy = topOffset;
let w = columnWidth;
const gap = padding;
let cellSize = 0;
let numRows = 0;
let columnIndex = 0;
const tabs = items.filter(({ type }) => type === "tab");
const defaultDy = tabs.length
? topOffset + tabHeight + padding * 3
: topOffset;
tabs.forEach((tab) => {
ctx.fillStyle = this.theme.background;
ctx.fillRect(dx, dy, w, tabHeight + padding * 2);
ctx.fillStyle = tab.current
? this.theme.tabBackgroundActive
: this.theme.tabBackground;
const x = dx + padding;
const y = dy + padding;
const width = w - padding * 2;
ctx.fillRect(x, y, width, tabHeight);
if (!tab.current) {
ctx.fillStyle = this.theme.background;
ctx.fillRect(x, dy + tabHeight + padding / 2, width, padding / 2);
}
ctx.fillStyle = tab.current
? this.theme.tabColorActive
: this.theme.tabColor;
ctx.fillText(
tab.title,
x + textPadding,
y + tabHeight / 2 + fontCapOffset,
);
rectSet4(tab.activeArea, x, y, width, tabHeight);
dx += w + gap;
});
dx = leftOffset;
let maxWidth = 0;
let maxHeight = 0;
let needInitialDy = true;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (Number.isFinite(item.x)) dx = item.x;
if (Number.isFinite(item.y)) dy = item.y;
let eh = itemHeight;
if (item.type === "tab") continue;
if (tabs.length > 0) {
const prevTabs = items.filter(
({ type }, index) => index < i && type === "tab",
);
const parentTab = prevTabs[prevTabs.length - 1];
if (parentTab && !parentTab.current) {
continue;
} else {
if (needInitialDy && item.type !== "column") {
needInitialDy = false;
dy += tabHeight + padding * 3;
}
}
needInitialDy = false;
}
const x = dx + padding;
const width = w - padding * 2;
const textY = titleHeight / 2 + fontCapOffset;
// Compute item height
if (item.type === "column") {
dx = leftOffset + columnIndex * (w + gap);
dy = defaultDy;
w = item.width;
columnIndex++;
continue;
} else if (item.type === "slider") {
eh = titleHeight + sliderHeight;
} else if (item.type === "toggle") {
eh = padding + itemHeight;
} else if (item.type === "multislider") {
const numSliders = item.getValue().length;
eh =
titleHeight + numSliders * sliderHeight + (numSliders - 1) * padding;
} else if (item.type === "color") {
const numSliders = item.options.alpha ? 4 : 3;
const sliderGap = item.options.paletteImage ? 0 : 1;
eh =
titleHeight +
numSliders * sliderHeight +
(numSliders - sliderGap) * padding;
if (item.options.paletteImage) {
eh += width * item.options.paletteImage.aspectRatio;
}
} else if (item.type === "button") {
eh = padding + buttonHeight;
} else if (item.type === "texture2D") {
eh = titleHeight + (item.texture.height * width) / item.texture.width;
} else if (item.type === "textureCube") {
eh = titleHeight + width / 2;
} else if (item.type === "radiolist") {
eh =
titleHeight +
item.items.length * itemHeight +
(item.items.length - 1) * padding * 2;
} else if (item.type === "texturelist") {
cellSize = Math.floor(width / item.itemsPerRow);
numRows = Math.ceil(item.items.length / item.itemsPerRow);
eh = titleHeight + numRows * cellSize;
} else if (item.type === "header") {
eh = padding + headerSize;
} else if (item.type === "text") {
eh = titleHeight + buttonHeight;
} else if (item.type === "graph") {
eh = titleHeight + graphHeight;
} else if (item.type === "stats") {
eh =
Object.values(item.stats)
.map((value) => String(value).split("\n").length)
.reduce((a, b) => a + b, 0) * itemHeight;
if (item.title !== "") eh += titleHeight;
} else if (item.type === "label") {
eh = item.title.split("\n").length * itemHeight + padding * 0.5;
}
const needsPadding = item.type !== "column";
// Draw background
if (item.type === "separator") {
eh /= 2;
} else {
ctx.fillStyle = this.theme.background;
ctx.fillRect(dx, dy, w, eh + (needsPadding ? padding : 0));
}
// Draw item
if (item.type === "slider") {
const y = dy + titleHeight;
const height = eh - titleHeight;
ctx.fillStyle = this.theme.color;
ctx.fillText(
`${item.title}: ${item.getStrValue()}`,
x + textPadding,
dy + textY,
);
ctx.fillStyle = this.theme.input;
ctx.fillRect(x, y, width, height);
ctx.fillStyle = this.theme.accent;
ctx.fillRect(x, y, width * item.getNormalizedValue(), height);
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "multislider" || item.type === "color") {
const isColor = item.type === "color";
const y = dy + titleHeight;
const height = eh - titleHeight;
const numSliders = isColor
? item.options.alpha
? 4
: 3
: item.getValue().length;
ctx.fillStyle = this.theme.color;
ctx.fillText(
`${item.title}: ${item.getStrValue()}`,
x + textPadding,
dy + textY,
);
for (let j = 0; j < numSliders; j++) {
const sliderY = y + j * (sliderHeight + padding);
ctx.fillStyle = this.theme.input;
ctx.fillRect(x, sliderY, width, sliderHeight);
ctx.fillStyle = this.theme.accent;
ctx.fillRect(
x,
sliderY,
width * item.getNormalizedValue(j),
sliderHeight,
);
}
if (isColor) {
const sqSize = titleHeight * 0.6;
ctx.fillStyle = toHex(item.contextObject[item.attributeName]);
ctx.fillRect(
dx + w - sqSize - padding,
dy + titleHeight * 0.2,
sqSize,
sqSize,
);
if (item.options?.palette && !item.options.paletteImage) {
if (item.options.palette.width) {
makePaletteImage(item, w, item.options.palette);
} else {
const img = new Image();
img.onload = () => {
makePaletteImage(item, w, img);
};
img.src = item.options.palette;
}
}
if (item.options.paletteImage) {
ctx.drawImage(
item.options.paletteImage,
x,
y + (sliderHeight + padding) * numSliders,
width,
width * item.options.paletteImage.aspectRatio,
);
}
}
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "button") {
const y = dy + padding;
const height = buttonHeight;
ctx.fillStyle = item.active ? this.theme.accent : this.theme.input;
ctx.fillRect(x, y, width, height);
ctx.fillStyle = item.active ? this.theme.input : this.theme.color;
ctx.fillText(
item.title,
x + textPadding * 2,
y + height / 2 + fontCapOffset,
);
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "toggle") {
const y = dy + padding;
const height = itemHeight;
ctx.fillStyle = item.contextObject[item.attributeName]
? this.theme.accent
: this.theme.input;
ctx.fillRect(x, y, height, height);
ctx.fillStyle = this.theme.color;
ctx.fillText(
item.title,
x + itemHeight + textPadding * 2,
dy + padding + itemHeight / 2 + fontCapOffset,
);
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "radiolist") {
const y = dy + titleHeight;
const height =
item.items.length * itemHeight +
(item.items.length - 1) * 2 * padding;
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
for (let j = 0; j < item.items.length; j++) {
const i = item.items[j];
const radioY = j * (itemHeight + padding * 2);
ctx.fillStyle =
item.contextObject[item.attributeName] === i.value
? this.theme.accent
: this.theme.input;
ctx.fillRect(x, y + radioY, itemHeight, itemHeight);
ctx.fillStyle = this.theme.color;
ctx.fillText(
i.name,
x + itemHeight + textPadding * 2,
titleHeight + radioY + dy + itemHeight / 2 + fontCapOffset,
);
}
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "texturelist") {
const y = dy + titleHeight;
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
for (let j = 0; j < item.items.length; j++) {
const col = j % item.itemsPerRow;
const row = Math.floor(j / item.itemsPerRow);
const itemX = x + col * cellSize;
const itemY = dy + titleHeight + row * cellSize;
let shrink = 0;
if (item.items[j].value === item.contextObject[item.attributeName]) {
ctx.fillStyle = "none";
ctx.strokeStyle = this.theme.accent;
ctx.lineWidth = padding;
ctx.strokeRect(
itemX + padding * 0.5,
itemY + padding * 0.5,
cellSize - 1 - padding,
cellSize - 1 - padding,
);
ctx.lineWidth = 1;
shrink = padding;
}
item.items[j].activeArea ||= [
[0, 0],
[0, 0],
];
rectSet4(
item.items[j].activeArea,
itemX + shrink,
itemY + shrink,
cellSize - 1 - 2 * shrink,
cellSize - 1 - 2 * shrink,
);
}
rectSet4(item.activeArea, x, y, width, cellSize * numRows);
} else if (item.type === "texture2D") {
const y = dy + titleHeight;
const height = eh - titleHeight;
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "textureCube") {
const y = dy + titleHeight;
const height = eh - titleHeight;
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "header") {
ctx.fillStyle = this.theme.headerBackground;
ctx.fillRect(x, dy + padding, width, eh - padding);
ctx.fillStyle = this.theme.headerColor;
ctx.fillText(
item.title,
x + textPadding,
dy + padding + headerSize / 2 + fontCapOffset,
);
} else if (item.type === "text") {
const y = dy + titleHeight;
const height = eh - titleHeight;
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
ctx.fillStyle = this.theme.input;
ctx.fillRect(
x,
y,
item.activeArea[1][0] - item.activeArea[0][0],
item.activeArea[1][1] - item.activeArea[0][1],
);
ctx.fillStyle = this.theme.color;
ctx.fillText(
item.contextObject[item.attributeName],
x + textPadding * 2,
y + buttonHeight / 2 + fontCapOffset,
);
if (item.focus) {
ctx.strokeStyle = this.theme.accent;
ctx.strokeRect(
item.activeArea[0][0] - 0.5,
item.activeArea[0][1] - 0.5,
item.activeArea[1][0] - item.activeArea[0][0],
item.activeArea[1][1] - item.activeArea[0][1],
);
}
rectSet4(item.activeArea, x, y, width, height);
} else if (item.type === "graph") {
const y = dy + titleHeight;
const height = eh - titleHeight;
if (item.values.length > width) item.values = item.values.slice(-width);
if (item.values.length) {
item.max = item.options.max ?? Math.max(...item.values);
}
if (item.values.length) {
item.min = item.options.min ?? Math.min(...item.values);
}
ctx.fillStyle = this.theme.graphBackground;
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = this.theme.background;
ctx.beginPath();
ctx.moveTo(x, y + padding);
ctx.lineTo(x + width, y + padding);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y + height - padding);
ctx.lineTo(x + width, y + height - padding);
ctx.closePath();
ctx.stroke();
ctx.fillStyle = this.theme.color;
ctx.save();
ctx.font = `${fontSize * 0.5}px ${fontFamily}`;
ctx.textAlign = "right";
const textX = x + width - padding;
if (item.max !== undefined) {
ctx.fillText(item.options.format(item.max), textX, y + padding * 2.5);
}
if (item.min !== undefined) {
ctx.fillText(
item.options.format(item.min),
textX,
y + height - padding * 2.5,
);
}
ctx.restore();
ctx.strokeStyle = this.theme.color;
ctx.beginPath();
for (let j = 0; j < item.values.length; j++) {
const v = utils.remap(item.values[j], item.min, item.max, 0, 1);
ctx[j === 0 ? "moveTo" : "lineTo"](
x + j,
y + height - v * (height - padding * 2) - padding,
);
}
ctx.stroke();
ctx.fillText(
`${item.title}: ${item.options.format(item.values[item.values.length - 1])}`,
x + textPadding,
dy + textY,
);
} else if (item.type === "stats") {
ctx.fillStyle = this.theme.color;
let lineIndex = 0;
if (item.title) {
ctx.fillText(item.title, x + textPadding, dy + textY);
lineIndex++;
}
for (let [name, value] of Object.entries(item.stats)) {
const lines = String(value).split("\n");
for (let j = 0; j < lines.length; j++) {
const lineValue = lines[j];
ctx.fillText(
j === 0 ? `${name}: ${lineValue}` : lineValue,
x + textPadding * 2,
dy + textY + itemHeight * lineIndex,
);
lineIndex++;
}
}
} else if (item.type === "label") {
ctx.fillStyle = this.theme.color;
const lines = item.title.split("\n");
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], x + textPadding, dy + textY + itemHeight * i);
}
} else if (item.type === "separator") {
// Nothing to draw, just increase the gap.
} else {
ctx.fillStyle = this.theme.color;
ctx.fillText(item.title, x + textPadding, dy + textY);
}
dy += eh + (needsPadding ? padding : 0) + gap;
maxWidth = Math.max(maxWidth, dx + w + leftOffset);
maxHeight = Math.max(maxHeight, dy + topOffset);
}
this.afterDraw();
ctx.restore();
maxWidth = Math.max(maxWidth, tabs.length * (w + gap));
if (maxWidth && maxHeight) {
maxWidth = (maxWidth * this.pixelRatio) | 0;
maxHeight = (maxHeight * this.pixelRatio) | 0;
if (this.canvas.width !== maxWidth) {
this.canvas.width = maxWidth;
this.dirty = true;
}
if (this.canvas.height !== maxHeight) {
this.canvas.height = maxHeight;
this.dirty = true;
}
if (this.dirty) {
this.draw(items);
}
}
}
afterDraw() {}
getTexture() {
return this.canvas;
}
dispose() {
this.canvas.remove();
}
}
export default CanvasRenderer;