diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fc66009 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +# TODO + +- Add click events +- Add keystrokes event for web displays +- Fix resizing on web_div display +- Optimize drawing to display, by allowing sequences of characters with the same + colors (if supported by the display) diff --git a/ansi.test.ts b/ansi.test.ts index c9b2625..0267553 100644 --- a/ansi.test.ts +++ b/ansi.test.ts @@ -13,10 +13,12 @@ function createTestDisplay(): { } describe(AnsiTerminalDisplay, () => { - it("clears the screen", async () => { + it("clears the screen on init", async () => { const { stdout, display } = createTestDisplay(); - await display.clear(); + await display.init(); checkSequence(stdout, "![2J"); + await display.uninit(); + checkSequence(stdout, "![2J![2J"); }); it("writes truecolor characters", async () => { diff --git a/ansi.ts b/ansi.ts index 2927656..941bbbe 100644 --- a/ansi.ts +++ b/ansi.ts @@ -27,6 +27,17 @@ export class AnsiTerminalDisplay implements Display { } } + async init(): Promise { + await this.writer.write(CLEAR); + } + + async uninit(): Promise { + await this.writer.write(CLEAR); + } + + async flush(): Promise { + } + async getSize(): Promise { const size = Deno.consoleSize(Deno.stdout.rid); this.width = size.columns; @@ -68,10 +79,6 @@ export class AnsiTerminalDisplay implements Display { } } - async clear(): Promise { - await this.writer.write(CLEAR); - } - async setCursorVisibility(visible: boolean): Promise { await this.writer.write(visible ? escape("[?25h") : escape("[?25l")); } diff --git a/base.test.ts b/base.test.ts index 12aecb4..f607af0 100644 --- a/base.test.ts +++ b/base.test.ts @@ -14,6 +14,8 @@ describe(CharBuffer, () => { it("keeps track of dirty lines", async () => { const buffer = new CharBuffer({ w: 16, h: 3 }); + expect(buffer.isDirty({ x: 0, y: 0 })).toBe(true); + buffer.setDirty(false); expect(buffer.isDirty({ x: 0, y: 0 })).toBe(false); buffer.set({ x: 0, y: 0 }, { ch: "x", bg: 0, fg: 0 }); expect(buffer.isDirty({ x: 0, y: 0 })).toBe(true); diff --git a/base.ts b/base.ts index 60eb320..fac0dcf 100644 --- a/base.ts +++ b/base.ts @@ -37,8 +37,8 @@ export class CharBuffer { constructor(private size: BufferSize) { this.chars = new Array(size.w * size.h).fill(SPACE); - this.dirty = false; - this.dirty_lines = new Uint8Array(size.h).fill(0x00); + this.dirty = true; + this.dirty_lines = new Uint8Array(size.h).fill(0xFF); this.dirty_seg_length = Math.ceil(size.w / 8); } diff --git a/config.ts b/config.ts index 11bbb1c..8b65586 100644 --- a/config.ts +++ b/config.ts @@ -9,6 +9,8 @@ import { Color } from "./base.ts"; * * For each palette index, a single color can be requested, or an * array of accepted alternatives, with decreasing priority. + * + * The first color is considered the default background color. */ export type UIPalette = ReadonlyArray>; @@ -19,22 +21,23 @@ export type UIConfig = Readonly<{ // Set the automatic loop interval on init // If left undefined, a call to ui.loop() will be required after init loopInterval?: number; - // Set if anything is rendered at all - renderingEnabled: boolean; // Ignore the ctrl+c key combo to quit the UI ignoreCtrlC: boolean; // Initially hide the cursor hideCursor: boolean; // Palette of colors that will be used by the UI palette: UIPalette; + // Callback to receive resizes + // The screen has been cleared before this is called + onResize: (size: { w: number; h: number }) => void; // Callback to receive key strokes onKeyStroke: (key: string) => void; }>; export const UI_CONFIG_DEFAULTS: UIConfig = { - renderingEnabled: true, ignoreCtrlC: false, hideCursor: true, palette: [], + onResize: (size) => {}, onKeyStroke: () => {}, }; diff --git a/demo.ts b/demo.ts index cc9d784..416bbea 100755 --- a/demo.ts +++ b/demo.ts @@ -12,6 +12,7 @@ const config: Partial = { { r: 1, g: 1, b: 1 }, { r: 0, g: 1, b: 1 }, ], + onResize: draw, onKeyStroke: (key) => { ui.drawing.color(1, 0).text(key, { x, y: 7 }); x += key.length + 1; @@ -19,6 +20,10 @@ const config: Partial = { }; const ui = new TextUI(display, config); await ui.init(); -ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 }); -ui.drawing.color(0, 1).text("world", { x: 10, y: 5 }); +draw(); await ui.loop(); + +function draw() { + ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 }); + ui.drawing.color(0, 1).text("world", { x: 10, y: 5 }); +} diff --git a/display.ts b/display.ts index 406e982..95d27a8 100644 --- a/display.ts +++ b/display.ts @@ -5,10 +5,22 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; */ export class Display { /** - * Get the displayable grid size + * Init the display (will be the first method called) + */ + async init(): Promise { + } + + /** + * Restore the display as before *init* + */ + async uninit(): Promise { + } + + /** + * Get the current grid size */ async getSize(): Promise { - return { w: 1, h: 1 }; + return { w: 0, h: 0 }; } /** @@ -23,18 +35,18 @@ export class Display { return []; } - /** - * Clear the whole screen - */ - async clear(): Promise { - } - /** * Set the cursor visibility */ async setCursorVisibility(visible: boolean): Promise { } + /** + * Flush the display + */ + async flush(): Promise { + } + /** * Draw a single character on screen */ diff --git a/mod.ts b/mod.ts index ed1a25b..8e67a47 100644 --- a/mod.ts +++ b/mod.ts @@ -2,13 +2,20 @@ import { AnsiTerminalDisplay } from "./ansi.ts"; import { UIConfig } from "./config.ts"; import { Display } from "./display.ts"; import { TextUI } from "./ui.ts"; -import { PreTerminalDisplay } from "./web.ts"; -export { TextUI } from "./ui.ts"; +import { + CanvasTerminalDisplay, + DivTerminalDisplay, + PreTerminalDisplay, +} from "./web.ts"; + +export { TextUI }; export const UI_DISPLAY_TYPES = { autodetect: undefined, ansi: AnsiTerminalDisplay, web_pre: PreTerminalDisplay, + web_div: DivTerminalDisplay, + web_canvas: CanvasTerminalDisplay, dummy: Display, } as const; @@ -18,7 +25,7 @@ export async function createTextUI( ): Promise { if (display_type == "autodetect") { if (typeof (window as any).document != "undefined") { - display_type = "web_pre"; + display_type = "web_canvas"; } else { display_type = "ansi"; } diff --git a/ui.test.ts b/ui.test.ts index d9ad816..dc7915c 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -1,4 +1,4 @@ -import { BufferLocation, BufferSize, Char, Color, SPACE } from "./base.ts"; +import { BufferLocation, Char, Color, SPACE } from "./base.ts"; import { describe, expect, it } from "./testing.ts"; import { Display } from "./display.ts"; import { TextUI } from "./ui.ts"; @@ -43,6 +43,10 @@ class PaletteTestDisplay extends Display { super(); } + async getSize() { + return { w: 1, h: 1 }; + } + async setupPalette(colors: readonly Color[]): Promise { return this.palette; } diff --git a/ui.ts b/ui.ts index 704deff..e7ce785 100644 --- a/ui.ts +++ b/ui.ts @@ -14,7 +14,7 @@ import { Display } from "./display.ts"; */ export class TextUI { private config: UIConfig; - private screen = new CharBuffer({ w: 1, h: 1 }); + private buffer = new CharBuffer({ w: 1, h: 1 }); private palettemap: PaletteMap = []; private quitting = false; @@ -23,7 +23,7 @@ export class TextUI { } get drawing(): BufferDrawing { - return new BufferDrawing(this.screen, this.palettemap); + return new BufferDrawing(this.buffer, this.palettemap); } /** @@ -34,17 +34,17 @@ export class TextUI { */ async init(): Promise { var size = await this.display.getSize(); - this.screen = new CharBuffer(size); + this.buffer = new CharBuffer(size); + + await this.display.init(); this.palettemap = await getPaletteMapping( this.config.palette, this.display, ); - if (this.config.renderingEnabled) { - if (this.config.hideCursor) { - await this.display.setCursorVisibility(false); - } - await this.display.clear(); + if (this.config.hideCursor) { + await this.display.setCursorVisibility(false); } + await this.clear(); if (this.config.loopInterval) { this.loop(this.config.loopInterval); // purposefully not awaited @@ -56,10 +56,9 @@ export class TextUI { */ async quit(): Promise { this.quitting = true; - if (this.config.renderingEnabled) { - await this.display.clear(); - await this.display.setCursorVisibility(true); - } + await this.clear(); + await this.display.setCursorVisibility(true); + await this.display.uninit(); if (typeof Deno != "undefined") { Deno.exit(); } @@ -69,20 +68,38 @@ export class TextUI { * Get the current display size */ getSize(): BufferSize { - return this.screen.getSize(); + return this.buffer.getSize(); } /** * Flush the internal buffer to the display */ async flush(): Promise { - if (!this.config.renderingEnabled) { - return; - } - - await this.screen.forEachDirty((at, char) => + await this.buffer.forEachDirty((at, char) => this.display.setChar(at, char) ); + await this.display.flush(); + } + + /** + * Clear the whole screen + */ + async clear(bg = 0): Promise { + const { w, h } = this.getSize(); + const drawing = this.drawing.color(bg, bg); + for (let y = 0; y < h; y++) { + drawing.text(Array(w).fill(" ").join(""), { x: 0, y }); + } + await this.flush(); + } + + /** + * Resize the buffer to match the display + */ + async resize(size: BufferSize): Promise { + this.buffer = new CharBuffer(size); + await this.clear(); + this.config.onResize(size); } /** @@ -90,6 +107,14 @@ export class TextUI { */ async loop(refresh = 100): Promise { while (!this.quitting) { + // handle resize + const dsize = await this.display.getSize(); + const bsize = this.buffer.getSize(); + if (dsize.w != bsize.w || dsize.h != bsize.h) { + await this.resize(dsize); + } + + // handle keystrokes for (const key of await this.display.getKeyStrokes()) { if (!this.config.ignoreCtrlC && key == "ctrl+c") { await this.quit(); @@ -97,7 +122,11 @@ export class TextUI { this.config.onKeyStroke(key); } } + + // flush await this.flush(); + + // wait await new Promise((resolve) => setTimeout(resolve, refresh)); } } diff --git a/web-demo/canvas.html b/web-demo/canvas.html new file mode 100644 index 0000000..29e7344 --- /dev/null +++ b/web-demo/canvas.html @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web-demo/demo.css b/web-demo/demo.css new file mode 100644 index 0000000..b08f147 --- /dev/null +++ b/web-demo/demo.css @@ -0,0 +1,17 @@ +body { + overflow: hidden; + padding: 0; + margin: 0; + width: 100%; + height: 100%; + background: #000000; +} + +#display { + width: 100vw; + height: 100vh; +} + +pre { + color: #ffffff; +} diff --git a/web-demo/demo.js b/web-demo/demo.js new file mode 100644 index 0000000..b7b7bcf --- /dev/null +++ b/web-demo/demo.js @@ -0,0 +1,18 @@ +import { createTextUI } from "./textui.js"; + +export async function demo(display_type) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const ui = await createTextUI({ + palette: [ + { r: 0, g: 0, b: 0 }, + { r: 1, g: 1, b: 1 }, + { r: 0, g: 1, b: 1 }, + ], + onResize: draw, + }, display_type); + function draw() { + ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 }); + ui.drawing.color(0, 1).text("world", { x: 10, y: 5 }); + } + await ui.loop(); +} diff --git a/web-demo/div.html b/web-demo/div.html new file mode 100644 index 0000000..e87b179 --- /dev/null +++ b/web-demo/div.html @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web-demo/pre.html b/web-demo/pre.html index da526af..d213161 100644 --- a/web-demo/pre.html +++ b/web-demo/pre.html @@ -2,12 +2,10 @@ + diff --git a/web.ts b/web.ts index b80da7d..3f2a164 100644 --- a/web.ts +++ b/web.ts @@ -1,21 +1,43 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; import { Display } from "./display.ts"; -/** - * Base for all web-based terminal displays - */ +// +// 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 { + if (typeof window != "undefined" && window) { + window.addEventListener("resize", () => { + this.resizing = true; + this.updateGrid(); + this.resizing = false; + }); + } + this.updateGrid(); + } + + async uninit(): Promise { + } + + async flush(): Promise { + } async getSize(): Promise { - return { w: 40, h: 20 }; + return this.size; } async setupPalette(colors: readonly Color[]): Promise { - return []; - } - - async clear(): Promise { + this.palette = colors; + return colors; } async setCursorVisibility(visible: boolean): Promise { @@ -27,27 +49,90 @@ class WebDisplay implements Display { 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 + // + 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 - */ +// +// Basic terminal display using a single "pre" tag +// export class PreTerminalDisplay extends WebDisplay { element: any; - async clear(): Promise { + override async init(): Promise { + await super.init(); + if (!this.element) { this.element = this.document.createElement("pre"); - this.document.body.appendChild(this.element); + this.parent.appendChild(this.element); } - const { w, h } = await this.getSize(); + const { w, h } = this.size; const line = Array(w).fill(" ").join(""); this.element.textContent = Array(h).fill(line).join("\n"); } - async setChar(at: BufferLocation, char: Char): Promise { - const { w, h } = await this.getSize(); + 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 + @@ -55,8 +140,282 @@ export class PreTerminalDisplay extends WebDisplay { } } -/** - * DOM terminal display, using one div per char - */ -export class DOMTerminalDisplay extends WebDisplay { +// +// 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(): 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 { + 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 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) + })`; }