textui/web.ts

421 lines
10 KiB
TypeScript

import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
import { Display } from "./display.ts";
//
// Base for all web-based terminal displays
//
class WebDisplay implements Display {
readonly document = (window as any).document;
readonly parent = this.document.body;
spacing = 2;
font_height = 24;
char_size = this.estimateCharSize(this.font_height);
size = { w: 40, h: 20 };
palette: readonly Color[] = [];
resizing = false;
async init(): Promise<void> {
if (typeof window != "undefined" && window) {
window.addEventListener("resize", () => {
this.resizing = true;
this.updateGrid();
this.resizing = false;
});
}
this.updateGrid();
}
async uninit(): Promise<void> {
}
async flush(): Promise<void> {
}
async getSize(): Promise<BufferSize> {
return this.size;
}
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
this.palette = colors;
return colors;
}
async setCursorVisibility(visible: boolean): Promise<void> {
}
async setChar(at: BufferLocation, char: Char): Promise<void> {
}
async getKeyStrokes(): Promise<string[]> {
return [];
}
//
// Get the size in pixels of the target area
//
getTargetSize(): { x: number; y: number } {
return {
x: this.parent.clientWidth || 800,
y: this.parent.clientHeight || 600,
};
}
//
// Estimate the char size from a font height
//
estimateCharSize(font_height: number): { x: number; y: number } {
return {
x: font_height + this.spacing,
y: font_height + 2 + this.spacing,
};
}
//
// Update the grid to match the display size
//
updateGrid(): void {
const target_size = this.getTargetSize();
let char_size = { x: 0, y: 0 };
let font_height = 24;
let width = 0;
let height = 0;
let recomputeSize = () => {
char_size = this.estimateCharSize(font_height);
width = Math.floor(target_size.x / char_size.x);
height = Math.floor(target_size.y / char_size.y);
};
recomputeSize();
while ((width < 60 || height < 40) && font_height > 8) {
font_height -= 2;
recomputeSize();
}
while ((width > 80 || height > 60) && font_height < 48) {
font_height += 2;
recomputeSize();
}
console.debug("Resizing", { font_height, char_size, width, height });
this.font_height = font_height;
this.char_size = char_size;
this.size = { w: width, h: height };
}
}
//
// Basic terminal display using a single "pre" tag
//
export class PreTerminalDisplay extends WebDisplay {
element: any;
override async init(): Promise<void> {
await super.init();
if (!this.element) {
this.element = this.document.createElement("pre");
this.parent.appendChild(this.element);
}
const { w, h } = this.size;
const line = Array(w).fill(" ").join("");
this.element.textContent = Array(h).fill(line).join("\n");
}
override async uninit(): Promise<void> {
if (this.element) {
this.parent.removeChild(this.element);
this.element = null;
}
await super.uninit();
}
override async setChar(at: BufferLocation, char: Char): Promise<void> {
const { w, h } = this.size;
const offset = at.y * (w + 1) + at.x;
const text = this.element.textContent;
this.element.textContent = text.slice(0, offset) + char.ch +
text.slice(offset + 1);
}
}
//
// Terminal display using one div per char
//
export class DivTerminalDisplay extends WebDisplay {
divs: any[] = [];
override async init(): Promise<void> {
await super.init();
this.prepareDivs(this.size);
}
override async uninit(): Promise<void> {
this.deleteAllDivs();
await super.uninit();
}
override async setChar(at: BufferLocation, char: Char): Promise<void> {
if (this.resizing) {
return;
}
const cell = this.divs[at.y * this.size.w + at.x];
cell.style.color = color2RGB(this.palette[char.fg]);
cell.style.background = color2RGB(this.palette[char.bg]);
cell.innerHTML = char.ch == " " ? "&nbsp;" : char.ch;
}
//
// Remove all known divs
//
deleteAllDivs(): void {
for (const div of this.divs) {
this.parent.removeChild(div);
}
this.divs = [];
}
//
// Prepare the cells for rendering
//
// This is only needed when size changes
//
prepareDivs({ w, h }: BufferSize): void {
this.deleteAllDivs();
const divs = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const div = this.document.createElement("div");
div.style.position = "absolute";
div.style.left = `${x * this.char_size.x}px`;
div.style.top = `${y * this.char_size.y}px`;
div.style.fontFamily = "textui, monospace";
div.style.fontSize = `${this.font_height}px`;
div.style.width = `${this.char_size.x}px`;
div.style.height = `${this.char_size.y}px`;
div.style.overflow = "hidden";
this.parent.appendChild(div);
divs.push(div);
/*div.addEventListener("click", () => {
if (this.onclick) {
this.onclick({ x, y });
}
});*/
}
}
this.divs = divs;
}
}
//
// Terminal display using a canvas to draw characters
//
export class CanvasTerminalDisplay extends WebDisplay {
ratio: number;
draw: Canvas;
compose: Canvas;
present: Canvas;
constructor() {
super();
this.ratio = (window as any).devicePixelRatio || 1;
this.draw = new Canvas(this.document, undefined);
this.compose = new Canvas(this.document, undefined);
this.present = new Canvas(this.document, this.parent);
/*this.present.element.addEventListener("click", (ev) => {
if (this.onclick) {
this.onclick({
x: Math.round((ev.offsetX * this.ratio) / this.char_size.x),
y: Math.round((ev.offsetY * this.ratio) / this.char_size.y),
});
}
});*/
}
override getTargetSize(): { x: number; y: number } {
const { x, y } = super.getTargetSize();
return {
x: x * this.ratio,
y: y * this.ratio,
};
}
override updateGrid(): void {
super.updateGrid();
const swidth = this.char_size.x * this.size.w;
const sheight = this.char_size.y * this.size.h;
this.draw.resize(this.char_size.x, this.char_size.y, this.font_height);
this.compose.resize(
swidth * this.ratio,
sheight * this.ratio,
this.font_height,
);
this.present.resize(
swidth * this.ratio,
sheight * this.ratio,
this.font_height,
);
this.present.element.style.width = `${Math.floor(swidth)}px`;
this.present.element.style.height = `${Math.floor(sheight)}px`;
}
override async init(): Promise<void> {
await super.init();
// TODO add present canvas to parent
}
override async uninit(): Promise<void> {
// TODO remove canvases
await super.uninit();
}
override async flush(): Promise<void> {
this.present.blit(this.compose, 0, 0);
}
override async setChar(at: BufferLocation, char: Char): Promise<void> {
if (this.resizing) {
return;
}
this.drawChar(char, at);
this.compose.blit(
this.draw,
at.x * this.char_size.x,
at.y * this.char_size.y,
);
}
protected drawChar(char: Char, loc: BufferLocation): void {
this.draw.fill(this.palette[char.bg]);
this.draw.text(char.ch, this.palette[char.fg]);
}
estimateCharSize(font_height = this.font_height): { x: number; y: number } {
const canvas = new Canvas(this.document);
canvas.resize(font_height * 2, font_height * 2, font_height);
canvas.fill({ r: 0, g: 0, b: 0 });
for (let i = 33; i < 126; i++) {
canvas.text(String.fromCharCode(i), { r: 255, g: 255, b: 255 });
}
const pixels = canvas.getPixels();
let xmin = font_height,
xmax = font_height,
ymin = font_height,
ymax = font_height;
for (let y = 0; y < pixels.height; y++) {
for (let x = 0; x < pixels.width; x++) {
if (pixels.data[(y * pixels.height + x) * 4]) {
xmin = Math.min(xmin, x);
xmax = Math.max(xmax, x);
ymin = Math.min(ymin, y);
ymax = Math.max(ymax, y);
}
}
}
canvas.destroy();
return {
x: xmax - xmin + 1 + this.spacing,
y: ymax - ymin + 1 + this.spacing,
};
}
}
class Canvas {
readonly element: any;
readonly ctx: any;
constructor(readonly document: any, readonly parent?: any) {
this.element = document.createElement("canvas");
const ctx = this.element.getContext("2d");
if (!ctx) {
throw new Error("2d canvas context not available");
}
this.ctx = ctx;
if (parent) {
parent.appendChild(this.element);
}
}
destroy(): void {
if (this.parent) {
this.parent.removeChild(this.element);
}
}
getPixels(): any {
return this.ctx.getImageData(0, 0, this.element.width, this.element.height);
}
resize(width: number, height: number, font_height: number) {
this.element.width = Math.floor(width);
this.element.height = Math.floor(height);
this.ctx.font = `${font_height}px intrusion`;
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
}
clear(): void {
this.ctx.clearRect(0, 0, this.element.width, this.element.height);
}
fill(color: Color): void {
this.ctx.fillStyle = color2RGB(color);
this.ctx.fillRect(0, 0, this.element.width, this.element.height);
}
text(content: string, color: Color): void {
this.ctx.fillStyle = color2RGB(color);
this.ctx.fillText(
content,
Math.floor(this.element.width / 2),
Math.floor(this.element.height / 2),
);
}
blit(content: Canvas, x: number, y: number): void {
this.ctx.drawImage(content.element, x, y);
}
image(
content: any,
offset = { x: 0, y: 0 },
scale = { x: 1, y: 1 },
): void {
this.ctx.save();
this.ctx.scale(scale.x, scale.y);
this.ctx.drawImage(content, offset.x, offset.y);
this.ctx.restore();
}
colorize(color: Color): void {
const op = this.ctx.globalCompositeOperation;
this.ctx.globalCompositeOperation = "multiply";
this.fill(color);
this.ctx.globalCompositeOperation = op;
}
}
function color2RGB({ r, g, b }: Color): string {
return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${
Math.round(b * 255)
})`;
}