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 { 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 { } async flush(): Promise { } async getSize(): Promise { return this.size; } async setupPalette(colors: readonly Color[]): Promise { this.palette = colors; if (colors.length > 0) { this.parent.style.background = color2RGB(colors[0]); } return colors; } async setCursorVisibility(visible: boolean): Promise { } async setChar(at: BufferLocation, char: Char): Promise { } async getKeyStrokes(): Promise { 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 { 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 { if (this.element) { this.parent.removeChild(this.element); this.element = null; } await super.uninit(); } override async setChar(at: BufferLocation, char: Char): Promise { 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 { await super.init(); this.prepareDivs(this.size); } override async uninit(): Promise { this.deleteAllDivs(); await super.uninit(); } override async setChar(at: BufferLocation, char: Char): Promise { 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 { await super.init(); // TODO add present canvas to parent } override async uninit(): Promise { // TODO remove canvases await super.uninit(); } override async flush(): Promise { this.present.blit(this.compose, 0, 0); } override async setChar(at: BufferLocation, char: Char): Promise { 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", };