Add true color output

This commit is contained in:
Michaël Lemaire 2021-06-25 00:41:34 +02:00
parent c9292c0199
commit b0a8e2b5b7
12 changed files with 167 additions and 17 deletions

View file

@ -1,11 +1,29 @@
import { AnsiTerminalDisplay } from "./ansi.ts";
import { Buffer, describe, expect, it } from "./deps.test.ts";
import { Buffer, describe, expect, it } from "./testing.ts";
describe(AnsiTerminalDisplay, () => {
it("clears the screen", async () => {
const stdout = new Buffer();
const display = new AnsiTerminalDisplay(stdout);
await display.clear();
expect(stdout.bytes()).toEqual(new Uint8Array([27, 91, 50, 74]));
checkSequence(stdout, "![2J");
});
it("writes colored characters", async () => {
const stdout = new Buffer();
const display = new AnsiTerminalDisplay(stdout);
await display.setupPalette([
{ r: 0.0, g: 0.0, b: 0.0 },
{ r: 0.5, g: 0.1, b: 1.0 },
]);
await display.setChar({ x: 0, y: 0 }, { ch: "$", fg: 1, bg: 0 });
checkSequence(stdout, "![38;2;128;26;255m![48;2;0;0;0m![0;0H$");
});
});
function checkSequence(buffer: Buffer, expected: string) {
const decoded = new TextDecoder().decode(
buffer.bytes().map((x) => x == 0x1B ? 0x21 : x),
);
expect(decoded).toEqual(expected);
}

18
ansi.ts
View file

@ -5,6 +5,9 @@ import { Display } from "./display.ts";
* ANSI terminal display
*/
export class AnsiTerminalDisplay implements Display {
private palette_bg: readonly Uint8Array[] = [];
private palette_fg: readonly Uint8Array[] = [];
constructor(
private writer: Deno.Writer = Deno.stdout,
private reader: Deno.Reader = Deno.stdin,
@ -20,6 +23,14 @@ export class AnsiTerminalDisplay implements Display {
}
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
// TODO handle not fully rgb compatible terminals
const cr = (x: number) => Math.round(x * 255);
this.palette_bg = colors.map((col) =>
escape(`[48;2;${cr(col.r)};${cr(col.g)};${cr(col.b)}m`)
);
this.palette_fg = colors.map((col) =>
escape(`[38;2;${cr(col.r)};${cr(col.g)};${cr(col.b)}m`)
);
return colors;
}
@ -28,7 +39,12 @@ export class AnsiTerminalDisplay implements Display {
}
async setChar(at: BufferLocation, char: Char): Promise<void> {
// TODO colors
// TODO do not move the cursor if already at good location
// TODO do not change the color if already good
const fg = this.palette_fg[char.fg];
const bg = this.palette_bg[char.bg];
await this.writer.write(fg);
await this.writer.write(bg);
await this.writer.write(escape(`[${at.y};${at.x}H${char.ch}`));
}
}

View file

@ -1,5 +1,5 @@
import { BufferDrawing, CharBuffer } from "./base.ts";
import { Buffer, describe, expect, it } from "./deps.test.ts";
import { describe, expect, it } from "./testing.ts";
describe(CharBuffer, () => {
it("initializes empty, sets and gets characters", () => {
@ -16,7 +16,7 @@ describe(CharBuffer, () => {
describe(BufferDrawing, () => {
it("draws text", () => {
const buffer = new CharBuffer({ w: 4, h: 2 });
const drawing = new BufferDrawing(buffer);
const drawing = new BufferDrawing(buffer, []);
drawing.text("testing", { x: -1, y: 0 });
drawing.text("so", { x: 1, y: 1 });
expect(buffer.toString()).toEqual("esti so ");

27
base.ts
View file

@ -7,6 +7,8 @@ export type Color = {
b: number;
};
export type PaletteMap = ReadonlyArray<number>;
/**
* Displayable character, with background and foreground color taken from the palette
*/
@ -39,7 +41,7 @@ export class CharBuffer {
*/
get(at: BufferLocation): Char {
const i = at.y * this.size.w + at.x;
if (i > 0 && i < this.chars.length) {
if (i >= 0 && i < this.chars.length) {
return this.chars[i];
} else {
return SPACE;
@ -72,23 +74,36 @@ export class CharBuffer {
* Tools for drawing inside a display buffer
*/
export class BufferDrawing {
constructor(private readonly buffer: CharBuffer) {
private bg = 0;
private fg = 0;
constructor(
private readonly buffer: CharBuffer,
private readonly palettemap: PaletteMap,
) {
}
color(fg: number, bg: number): BufferDrawing {
this.fg = this.palettemap[fg];
this.bg = this.palettemap[bg];
return this;
}
/**
* Draw a piece of text of the same color
*/
text(content: string, from: BufferLocation): void {
let { w, h } = this.buffer.getSize();
text(content: string, from: BufferLocation): BufferDrawing {
const { bg, fg, buffer } = this;
const { w, h } = buffer.getSize();
let { x, y } = from;
let buf = this.buffer;
if (y >= 0 && y < h) {
for (let ch of content) {
if (x >= 0 && x < w) {
buf.set({ x, y }, { ch, bg: 0, fg: 0 });
buffer.set({ x, y }, { ch, bg, fg });
}
x++;
}
}
return this;
}
}

1
config/run.flags Normal file
View file

@ -0,0 +1 @@
--allow-env --unstable

1
config/test.flags Normal file
View file

@ -0,0 +1 @@
--unstable

1
config/types.flags Normal file
View file

@ -0,0 +1 @@
--unstable

10
demo.ts
View file

@ -1,11 +1,15 @@
#!/usr/bin/env -S deno run --allow-env --unstable
#!./run
import { AnsiTerminalDisplay } from "./ansi.ts";
import { TextUI } from "./ui.ts";
const display = new AnsiTerminalDisplay();
const ui = new TextUI(display);
await ui.init();
ui.drawing.text("hello", { x: 10, y: 3 });
await ui.init([
{ r: 0, g: 0, b: 0 },
{ r: 1, g: 1, b: 1 },
{ r: 0, g: 1, b: 1 },
]);
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
await ui.flush();
await new Promise((resolve) => setTimeout(resolve, 3000));

19
run Executable file
View file

@ -0,0 +1,19 @@
#!/bin/sh
# Simplified run tool for deno commands
if test $# -eq 0
then
echo "Usage: $0 [file or command]"
exit 1
elif echo $1 | grep -q '.*.ts'
then
denocmd=run
denoargs=$1
shift
else
denocmd=$1
shift
fi
denoargs="$(cat config/$denocmd.flags 2> /dev/null) $denoargs $@"
exec deno $denocmd $denoargs

41
ui.test.ts Normal file
View file

@ -0,0 +1,41 @@
import { BufferLocation, BufferSize, Char, Color, SPACE } from "./base.ts";
import { describe, expect, it } from "./testing.ts";
import { Display } from "./display.ts";
import { TextUI } from "./ui.ts";
describe(TextUI, () => {
it("maps a full RGB color palette directly", async () => {
const palette = [
{ r: 0.0, g: 0.0, b: 0.0 },
{ r: 1.0, g: 0.0, b: 0.0 },
];
const display = new PaletteTestDisplay(palette);
const ui = new TextUI(display);
await ui.init(palette);
ui.drawing.color(1, 0).text("x", { x: 0, y: 0 });
await ui.flush();
expect(display.last).toEqual({ ch: "x", bg: 0, fg: 1 });
});
});
class PaletteTestDisplay implements Display {
last = SPACE;
constructor(public palette: Color[]) {
}
async getSize(): Promise<BufferSize> {
return { w: 1, h: 1 };
}
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
return this.palette;
}
async clear(): Promise<void> {
}
async setChar(at: BufferLocation, char: Char): Promise<void> {
this.last = char;
}
}

40
ui.ts
View file

@ -1,4 +1,10 @@
import { BufferDrawing, BufferSize, CharBuffer } from "./base.ts";
import {
BufferDrawing,
BufferSize,
CharBuffer,
Color,
PaletteMap,
} from "./base.ts";
import { Display } from "./display.ts";
/**
@ -6,20 +12,22 @@ import { Display } from "./display.ts";
*/
export class TextUI {
private screen = new CharBuffer({ w: 1, h: 1 });
private palettemap: PaletteMap = [];
constructor(private display: Display) {
}
get drawing(): BufferDrawing {
return new BufferDrawing(this.screen);
return new BufferDrawing(this.screen, this.palettemap);
}
/**
* Initializes the display
*/
async init(): Promise<void> {
async init(palette: UIPalette): Promise<void> {
var size = await this.display.getSize();
this.screen = new CharBuffer(size);
this.palettemap = await this.getPaletteMapping(palette);
await this.display.clear();
}
@ -52,4 +60,30 @@ export class TextUI {
await new Promise((resolve) => setTimeout(resolve, refresh));
}
}
private async getPaletteMapping(
palette: UIPalette,
): Promise<PaletteMap> {
// get the colors supported by display
const allcolors = palette
.map((c): Color[] => Array.isArray(c) ? c : [c])
.reduce((acc, val) => acc.concat(val), []);
const supported = await this.display.setupPalette(allcolors);
// TODO find the best color mapping for each source color
return palette.map((_, idx) => idx);
}
}
/**
* Color palette requirements.
*
* The array represents the "ideal" colors desired by the application.
* When drawing things, *bg* and *fg* color information should be an index
* in this palette.
*
* For each palette index, a single color can be requested, or an
* array of accepted alternatives, with decreasing priority.
*/
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;