486 lines
12 KiB
TypeScript
486 lines
12 KiB
TypeScript
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
|
import { Display } from "./display.ts";
|
|
|
|
//
|
|
// Base for all web-based terminal displays
|
|
//
|
|
class WebDisplay extends Display {
|
|
readonly document = (window as any).document;
|
|
readonly parent = this.document.getElementById("-textui-container-") ||
|
|
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;
|
|
});
|
|
window.addEventListener("keydown", (event) => {
|
|
const key = convertKeyEvent(event);
|
|
if (key !== null) {
|
|
this.pushEvent({ key });
|
|
}
|
|
});
|
|
}
|
|
this.parent.style.overflow = "hidden";
|
|
this.parent.style.padding = "0";
|
|
this.parent.style.margin = "0";
|
|
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;
|
|
if (colors.length > 0) {
|
|
this.parent.style.background = color2RGB(colors[0]);
|
|
}
|
|
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
|
|
//
|
|
// Returns true if the size changed
|
|
//
|
|
updateGrid(): boolean {
|
|
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();
|
|
}
|
|
|
|
if (
|
|
width != this.size.w || height != this.size.h ||
|
|
font_height != this.font_height || char_size.x != this.char_size.x ||
|
|
char_size.y != this.char_size.y
|
|
) {
|
|
console.debug("Resizing", {
|
|
target_size,
|
|
font_height,
|
|
char_size,
|
|
width,
|
|
height,
|
|
});
|
|
this.font_height = font_height;
|
|
this.char_size = char_size;
|
|
this.size = { w: width, h: height };
|
|
this.pushEvent({ size: this.size });
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// 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 == " " ? " " : 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(): boolean {
|
|
if (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`;
|
|
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 textui, monospace`;
|
|
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)
|
|
})`;
|
|
}
|
|
|
|
function convertKeyEvent(event: any): string | null {
|
|
if (!event.key) {
|
|
return null;
|
|
}
|
|
|
|
const keycode = event.key.toLocaleLowerCase();
|
|
let key = MAPPING_KEYS[keycode] ?? keycode;
|
|
|
|
if (event.shiftKey) {
|
|
key = "shift+" + key.toLocaleLowerCase();
|
|
}
|
|
if (event.altKey) {
|
|
key = "alt+" + key;
|
|
}
|
|
if (event.ctrlKey) {
|
|
key = "ctrl+" + key;
|
|
}
|
|
|
|
return key;
|
|
}
|
|
|
|
const MAPPING_KEYS: { [key: string]: string } = {
|
|
"arrowdown": "down",
|
|
"arrowup": "up",
|
|
"arrowleft": "left",
|
|
"arrowright": "right",
|
|
"enter": "return",
|
|
" ": "space",
|
|
};
|