Add div and canvas web displays

This commit is contained in:
Michaël Lemaire 2021-07-20 00:48:00 +02:00
parent 68105c49ff
commit 770651f428
17 changed files with 566 additions and 68 deletions

7
TODO.md Normal file
View File

@ -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)

View File

@ -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 () => {

15
ansi.ts
View File

@ -27,6 +27,17 @@ export class AnsiTerminalDisplay implements Display {
}
}
async init(): Promise<void> {
await this.writer.write(CLEAR);
}
async uninit(): Promise<void> {
await this.writer.write(CLEAR);
}
async flush(): Promise<void> {
}
async getSize(): Promise<BufferSize> {
const size = Deno.consoleSize(Deno.stdout.rid);
this.width = size.columns;
@ -68,10 +79,6 @@ export class AnsiTerminalDisplay implements Display {
}
}
async clear(): Promise<void> {
await this.writer.write(CLEAR);
}
async setCursorVisibility(visible: boolean): Promise<void> {
await this.writer.write(visible ? escape("[?25h") : escape("[?25l"));
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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<Color | ReadonlyArray<Color>>;
@ -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: () => {},
};

View File

@ -12,6 +12,7 @@ const config: Partial<UIConfig> = {
{ 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<UIConfig> = {
};
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 });
}

View File

@ -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<void> {
}
/**
* Restore the display as before *init*
*/
async uninit(): Promise<void> {
}
/**
* Get the current grid size
*/
async getSize(): Promise<BufferSize> {
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<void> {
}
/**
* Set the cursor visibility
*/
async setCursorVisibility(visible: boolean): Promise<void> {
}
/**
* Flush the display
*/
async flush(): Promise<void> {
}
/**
* Draw a single character on screen
*/

13
mod.ts
View File

@ -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<TextUI> {
if (display_type == "autodetect") {
if (typeof (window as any).document != "undefined") {
display_type = "web_pre";
display_type = "web_canvas";
} else {
display_type = "ansi";
}

View File

@ -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<readonly Color[]> {
return this.palette;
}

65
ui.ts
View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this.buffer = new CharBuffer(size);
await this.clear();
this.config.onResize(size);
}
/**
@ -90,6 +107,14 @@ export class TextUI {
*/
async loop(refresh = 100): Promise<void> {
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));
}
}

14
web-demo/canvas.html Normal file
View File

@ -0,0 +1,14 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { demo } from "./demo.js";
demo("web_canvas");
</script>
</head>
<body></body>
</html>

17
web-demo/demo.css Normal file
View File

@ -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;
}

18
web-demo/demo.js Normal file
View File

@ -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();
}

14
web-demo/div.html Normal file
View File

@ -0,0 +1,14 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { demo } from "./demo.js";
demo("web_div");
</script>
</head>
<body></body>
</html>

View File

@ -2,12 +2,10 @@
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="demo.css">
<script type="module">
import { createTextUI } from "./textui.js";
const ui = await createTextUI();
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();
import { demo } from "./demo.js";
demo("web_pre");
</script>
</head>

399
web.ts
View File

@ -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<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 { w: 40, h: 20 };
return this.size;
}
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return [];
}
async clear(): Promise<void> {
this.palette = colors;
return colors;
}
async setCursorVisibility(visible: boolean): Promise<void> {
@ -27,27 +49,90 @@ class WebDisplay implements Display {
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
*/
//
// Basic terminal display using a single "pre" tag
//
export class PreTerminalDisplay extends WebDisplay {
element: any;
async clear(): Promise<void> {
override async init(): Promise<void> {
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<void> {
const { w, h } = await this.getSize();
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 +
@ -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<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)
})`;
}