Add div and canvas web displays
This commit is contained in:
parent
68105c49ff
commit
770651f428
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, () => {
|
describe(AnsiTerminalDisplay, () => {
|
||||||
it("clears the screen", async () => {
|
it("clears the screen on init", async () => {
|
||||||
const { stdout, display } = createTestDisplay();
|
const { stdout, display } = createTestDisplay();
|
||||||
await display.clear();
|
await display.init();
|
||||||
checkSequence(stdout, "![2J");
|
checkSequence(stdout, "![2J");
|
||||||
|
await display.uninit();
|
||||||
|
checkSequence(stdout, "![2J![2J");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes truecolor characters", async () => {
|
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> {
|
async getSize(): Promise<BufferSize> {
|
||||||
const size = Deno.consoleSize(Deno.stdout.rid);
|
const size = Deno.consoleSize(Deno.stdout.rid);
|
||||||
this.width = size.columns;
|
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> {
|
async setCursorVisibility(visible: boolean): Promise<void> {
|
||||||
await this.writer.write(visible ? escape("[?25h") : escape("[?25l"));
|
await this.writer.write(visible ? escape("[?25h") : escape("[?25l"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@ describe(CharBuffer, () => {
|
||||||
|
|
||||||
it("keeps track of dirty lines", async () => {
|
it("keeps track of dirty lines", async () => {
|
||||||
const buffer = new CharBuffer({ w: 16, h: 3 });
|
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);
|
expect(buffer.isDirty({ x: 0, y: 0 })).toBe(false);
|
||||||
buffer.set({ x: 0, y: 0 }, { ch: "x", bg: 0, fg: 0 });
|
buffer.set({ x: 0, y: 0 }, { ch: "x", bg: 0, fg: 0 });
|
||||||
expect(buffer.isDirty({ x: 0, y: 0 })).toBe(true);
|
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) {
|
constructor(private size: BufferSize) {
|
||||||
this.chars = new Array(size.w * size.h).fill(SPACE);
|
this.chars = new Array(size.w * size.h).fill(SPACE);
|
||||||
this.dirty = false;
|
this.dirty = true;
|
||||||
this.dirty_lines = new Uint8Array(size.h).fill(0x00);
|
this.dirty_lines = new Uint8Array(size.h).fill(0xFF);
|
||||||
this.dirty_seg_length = Math.ceil(size.w / 8);
|
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
|
* For each palette index, a single color can be requested, or an
|
||||||
* array of accepted alternatives, with decreasing priority.
|
* array of accepted alternatives, with decreasing priority.
|
||||||
|
*
|
||||||
|
* The first color is considered the default background color.
|
||||||
*/
|
*/
|
||||||
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;
|
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;
|
||||||
|
|
||||||
|
@ -19,22 +21,23 @@ export type UIConfig = Readonly<{
|
||||||
// Set the automatic loop interval on init
|
// Set the automatic loop interval on init
|
||||||
// If left undefined, a call to ui.loop() will be required after init
|
// If left undefined, a call to ui.loop() will be required after init
|
||||||
loopInterval?: number;
|
loopInterval?: number;
|
||||||
// Set if anything is rendered at all
|
|
||||||
renderingEnabled: boolean;
|
|
||||||
// Ignore the ctrl+c key combo to quit the UI
|
// Ignore the ctrl+c key combo to quit the UI
|
||||||
ignoreCtrlC: boolean;
|
ignoreCtrlC: boolean;
|
||||||
// Initially hide the cursor
|
// Initially hide the cursor
|
||||||
hideCursor: boolean;
|
hideCursor: boolean;
|
||||||
// Palette of colors that will be used by the UI
|
// Palette of colors that will be used by the UI
|
||||||
palette: UIPalette;
|
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
|
// Callback to receive key strokes
|
||||||
onKeyStroke: (key: string) => void;
|
onKeyStroke: (key: string) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const UI_CONFIG_DEFAULTS: UIConfig = {
|
export const UI_CONFIG_DEFAULTS: UIConfig = {
|
||||||
renderingEnabled: true,
|
|
||||||
ignoreCtrlC: false,
|
ignoreCtrlC: false,
|
||||||
hideCursor: true,
|
hideCursor: true,
|
||||||
palette: [],
|
palette: [],
|
||||||
|
onResize: (size) => {},
|
||||||
onKeyStroke: () => {},
|
onKeyStroke: () => {},
|
||||||
};
|
};
|
||||||
|
|
9
demo.ts
9
demo.ts
|
@ -12,6 +12,7 @@ const config: Partial<UIConfig> = {
|
||||||
{ r: 1, g: 1, b: 1 },
|
{ r: 1, g: 1, b: 1 },
|
||||||
{ r: 0, g: 1, b: 1 },
|
{ r: 0, g: 1, b: 1 },
|
||||||
],
|
],
|
||||||
|
onResize: draw,
|
||||||
onKeyStroke: (key) => {
|
onKeyStroke: (key) => {
|
||||||
ui.drawing.color(1, 0).text(key, { x, y: 7 });
|
ui.drawing.color(1, 0).text(key, { x, y: 7 });
|
||||||
x += key.length + 1;
|
x += key.length + 1;
|
||||||
|
@ -19,6 +20,10 @@ const config: Partial<UIConfig> = {
|
||||||
};
|
};
|
||||||
const ui = new TextUI(display, config);
|
const ui = new TextUI(display, config);
|
||||||
await ui.init();
|
await ui.init();
|
||||||
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
|
draw();
|
||||||
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
|
|
||||||
await ui.loop();
|
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 {
|
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> {
|
async getSize(): Promise<BufferSize> {
|
||||||
return { w: 1, h: 1 };
|
return { w: 0, h: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -23,18 +35,18 @@ export class Display {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the whole screen
|
|
||||||
*/
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the cursor visibility
|
* Set the cursor visibility
|
||||||
*/
|
*/
|
||||||
async setCursorVisibility(visible: boolean): Promise<void> {
|
async setCursorVisibility(visible: boolean): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush the display
|
||||||
|
*/
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw a single character on screen
|
* 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 { UIConfig } from "./config.ts";
|
||||||
import { Display } from "./display.ts";
|
import { Display } from "./display.ts";
|
||||||
import { TextUI } from "./ui.ts";
|
import { TextUI } from "./ui.ts";
|
||||||
import { PreTerminalDisplay } from "./web.ts";
|
import {
|
||||||
export { TextUI } from "./ui.ts";
|
CanvasTerminalDisplay,
|
||||||
|
DivTerminalDisplay,
|
||||||
|
PreTerminalDisplay,
|
||||||
|
} from "./web.ts";
|
||||||
|
|
||||||
|
export { TextUI };
|
||||||
|
|
||||||
export const UI_DISPLAY_TYPES = {
|
export const UI_DISPLAY_TYPES = {
|
||||||
autodetect: undefined,
|
autodetect: undefined,
|
||||||
ansi: AnsiTerminalDisplay,
|
ansi: AnsiTerminalDisplay,
|
||||||
web_pre: PreTerminalDisplay,
|
web_pre: PreTerminalDisplay,
|
||||||
|
web_div: DivTerminalDisplay,
|
||||||
|
web_canvas: CanvasTerminalDisplay,
|
||||||
dummy: Display,
|
dummy: Display,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -18,7 +25,7 @@ export async function createTextUI(
|
||||||
): Promise<TextUI> {
|
): Promise<TextUI> {
|
||||||
if (display_type == "autodetect") {
|
if (display_type == "autodetect") {
|
||||||
if (typeof (window as any).document != "undefined") {
|
if (typeof (window as any).document != "undefined") {
|
||||||
display_type = "web_pre";
|
display_type = "web_canvas";
|
||||||
} else {
|
} else {
|
||||||
display_type = "ansi";
|
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 { describe, expect, it } from "./testing.ts";
|
||||||
import { Display } from "./display.ts";
|
import { Display } from "./display.ts";
|
||||||
import { TextUI } from "./ui.ts";
|
import { TextUI } from "./ui.ts";
|
||||||
|
@ -43,6 +43,10 @@ class PaletteTestDisplay extends Display {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSize() {
|
||||||
|
return { w: 1, h: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
||||||
return this.palette;
|
return this.palette;
|
||||||
}
|
}
|
||||||
|
|
65
ui.ts
65
ui.ts
|
@ -14,7 +14,7 @@ import { Display } from "./display.ts";
|
||||||
*/
|
*/
|
||||||
export class TextUI {
|
export class TextUI {
|
||||||
private config: UIConfig;
|
private config: UIConfig;
|
||||||
private screen = new CharBuffer({ w: 1, h: 1 });
|
private buffer = new CharBuffer({ w: 1, h: 1 });
|
||||||
private palettemap: PaletteMap = [];
|
private palettemap: PaletteMap = [];
|
||||||
private quitting = false;
|
private quitting = false;
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export class TextUI {
|
||||||
}
|
}
|
||||||
|
|
||||||
get drawing(): BufferDrawing {
|
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> {
|
async init(): Promise<void> {
|
||||||
var size = await this.display.getSize();
|
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.palettemap = await getPaletteMapping(
|
||||||
this.config.palette,
|
this.config.palette,
|
||||||
this.display,
|
this.display,
|
||||||
);
|
);
|
||||||
if (this.config.renderingEnabled) {
|
if (this.config.hideCursor) {
|
||||||
if (this.config.hideCursor) {
|
await this.display.setCursorVisibility(false);
|
||||||
await this.display.setCursorVisibility(false);
|
|
||||||
}
|
|
||||||
await this.display.clear();
|
|
||||||
}
|
}
|
||||||
|
await this.clear();
|
||||||
|
|
||||||
if (this.config.loopInterval) {
|
if (this.config.loopInterval) {
|
||||||
this.loop(this.config.loopInterval); // purposefully not awaited
|
this.loop(this.config.loopInterval); // purposefully not awaited
|
||||||
|
@ -56,10 +56,9 @@ export class TextUI {
|
||||||
*/
|
*/
|
||||||
async quit(): Promise<void> {
|
async quit(): Promise<void> {
|
||||||
this.quitting = true;
|
this.quitting = true;
|
||||||
if (this.config.renderingEnabled) {
|
await this.clear();
|
||||||
await this.display.clear();
|
await this.display.setCursorVisibility(true);
|
||||||
await this.display.setCursorVisibility(true);
|
await this.display.uninit();
|
||||||
}
|
|
||||||
if (typeof Deno != "undefined") {
|
if (typeof Deno != "undefined") {
|
||||||
Deno.exit();
|
Deno.exit();
|
||||||
}
|
}
|
||||||
|
@ -69,20 +68,38 @@ export class TextUI {
|
||||||
* Get the current display size
|
* Get the current display size
|
||||||
*/
|
*/
|
||||||
getSize(): BufferSize {
|
getSize(): BufferSize {
|
||||||
return this.screen.getSize();
|
return this.buffer.getSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush the internal buffer to the display
|
* Flush the internal buffer to the display
|
||||||
*/
|
*/
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
if (!this.config.renderingEnabled) {
|
await this.buffer.forEachDirty((at, char) =>
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.screen.forEachDirty((at, char) =>
|
|
||||||
this.display.setChar(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> {
|
async loop(refresh = 100): Promise<void> {
|
||||||
while (!this.quitting) {
|
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()) {
|
for (const key of await this.display.getKeyStrokes()) {
|
||||||
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
|
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
|
||||||
await this.quit();
|
await this.quit();
|
||||||
|
@ -97,7 +122,11 @@ export class TextUI {
|
||||||
this.config.onKeyStroke(key);
|
this.config.onKeyStroke(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flush
|
||||||
await this.flush();
|
await this.flush();
|
||||||
|
|
||||||
|
// wait
|
||||||
await new Promise((resolve) => setTimeout(resolve, refresh));
|
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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="demo.css">
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { createTextUI } from "./textui.js";
|
import { demo } from "./demo.js";
|
||||||
const ui = await createTextUI();
|
demo("web_pre");
|
||||||
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();
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
399
web.ts
399
web.ts
|
@ -1,21 +1,43 @@
|
||||||
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
||||||
import { Display } from "./display.ts";
|
import { Display } from "./display.ts";
|
||||||
|
|
||||||
/**
|
//
|
||||||
* Base for all web-based terminal displays
|
// Base for all web-based terminal displays
|
||||||
*/
|
//
|
||||||
class WebDisplay implements Display {
|
class WebDisplay implements Display {
|
||||||
readonly document = (window as any).document;
|
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> {
|
async getSize(): Promise<BufferSize> {
|
||||||
return { w: 40, h: 20 };
|
return this.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
||||||
return [];
|
this.palette = colors;
|
||||||
}
|
return colors;
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setCursorVisibility(visible: boolean): Promise<void> {
|
async setCursorVisibility(visible: boolean): Promise<void> {
|
||||||
|
@ -27,27 +49,90 @@ class WebDisplay implements Display {
|
||||||
async getKeyStrokes(): Promise<string[]> {
|
async getKeyStrokes(): Promise<string[]> {
|
||||||
return [];
|
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 {
|
export class PreTerminalDisplay extends WebDisplay {
|
||||||
element: any;
|
element: any;
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
override async init(): Promise<void> {
|
||||||
|
await super.init();
|
||||||
|
|
||||||
if (!this.element) {
|
if (!this.element) {
|
||||||
this.element = this.document.createElement("pre");
|
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("");
|
const line = Array(w).fill(" ").join("");
|
||||||
this.element.textContent = Array(h).fill(line).join("\n");
|
this.element.textContent = Array(h).fill(line).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChar(at: BufferLocation, char: Char): Promise<void> {
|
override async uninit(): Promise<void> {
|
||||||
const { w, h } = await this.getSize();
|
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 offset = at.y * (w + 1) + at.x;
|
||||||
const text = this.element.textContent;
|
const text = this.element.textContent;
|
||||||
this.element.textContent = text.slice(0, offset) + char.ch +
|
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
|
// Terminal display using one div per char
|
||||||
*/
|
//
|
||||||
export class DOMTerminalDisplay extends WebDisplay {
|
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