Add div and canvas web displays
This commit is contained in:
parent
68105c49ff
commit
770651f428
17 changed files with 566 additions and 68 deletions
7
TODO.md
Normal file
7
TODO.md
Normal 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)
|
|
@ -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
15
ansi.ts
|
@ -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"));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
4
base.ts
4
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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: () => {},
|
||||
};
|
||||
|
|
9
demo.ts
9
demo.ts
|
@ -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 });
|
||||
}
|
||||
|
|
28
display.ts
28
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<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
13
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<TextUI> {
|
||||
if (display_type == "autodetect") {
|
||||
if (typeof (window as any).document != "undefined") {
|
||||
display_type = "web_pre";
|
||||
display_type = "web_canvas";
|
||||
} else {
|
||||
display_type = "ansi";
|
||||
}
|
||||
|
|
|
@ -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
65
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<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
14
web-demo/canvas.html
Normal 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
17
web-demo/demo.css
Normal 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
18
web-demo/demo.js
Normal 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
14
web-demo/div.html
Normal 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>
|
|
@ -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
399
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<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 == " " ? " " : 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)
|
||||
})`;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue