Add 256 color support
This commit is contained in:
parent
5b7f4720f3
commit
731f601cdb
42
ansi.test.ts
42
ansi.test.ts
|
@ -1,4 +1,4 @@
|
||||||
import { AnsiTerminalDisplay } from "./ansi.ts";
|
import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts";
|
||||||
import { Buffer, describe, expect, it } from "./testing.ts";
|
import { Buffer, describe, expect, it } from "./testing.ts";
|
||||||
|
|
||||||
describe(AnsiTerminalDisplay, () => {
|
describe(AnsiTerminalDisplay, () => {
|
||||||
|
@ -9,17 +9,34 @@ describe(AnsiTerminalDisplay, () => {
|
||||||
checkSequence(stdout, "![2J");
|
checkSequence(stdout, "![2J");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes colored characters", async () => {
|
it("writes truecolor characters", async () => {
|
||||||
const stdout = new Buffer();
|
const stdout = new Buffer();
|
||||||
const display = new AnsiTerminalDisplay(stdout);
|
const display = new AnsiTerminalDisplay(stdout);
|
||||||
await display.setupPalette([
|
await display.setupPalette([
|
||||||
{ r: 0.0, g: 0.0, b: 0.0 },
|
{ r: 0.0, g: 0.0, b: 0.0 },
|
||||||
{ r: 0.5, g: 0.1, b: 1.0 },
|
{ r: 0.5, g: 0.1, b: 1.0 },
|
||||||
]);
|
], AnsiColorMode.TRUECOLOR);
|
||||||
await display.setChar({ x: 0, y: 0 }, { ch: "$", fg: 1, bg: 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![1;1H$");
|
checkSequence(stdout, "![38;2;128;26;255m![48;2;0;0;0m![1;1H$");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to 256 colors", async () => {
|
||||||
|
const stdout = new Buffer();
|
||||||
|
const display = new AnsiTerminalDisplay(stdout);
|
||||||
|
const palette = await display.setupPalette([
|
||||||
|
{ r: 0.0, g: 0.0, b: 0.0 },
|
||||||
|
], AnsiColorMode.COLORS256);
|
||||||
|
await display.setChar({ x: 0, y: 0 }, { ch: "a", fg: 0, bg: 1 });
|
||||||
|
await display.setChar({ x: 0, y: 0 }, { ch: "b", fg: 2, bg: 3 });
|
||||||
|
checkSequence(stdout, "![38;5;0m![48;5;1m![1;1Ha![38;5;2m![48;5;3m![1;1Hb");
|
||||||
|
|
||||||
|
expect(palette[0]).toEqual({ r: 0, g: 0, b: 0 });
|
||||||
|
expect(palette[3]).toEqual({ r: 0.5, g: 0.5, b: 0 });
|
||||||
|
expect(palette[14]).toEqual({ r: 0, g: 1, b: 1 });
|
||||||
|
expect(palette[67]).toEqual({ r: 0.2, g: 0.4, b: 0.6 });
|
||||||
|
expect(palette[234]).toEqual({ r: 0.12, g: 0.12, b: 0.12 });
|
||||||
|
});
|
||||||
|
|
||||||
it("moves the cursor only when needed", async () => {
|
it("moves the cursor only when needed", async () => {
|
||||||
const stdout = new Buffer();
|
const stdout = new Buffer();
|
||||||
const display = new AnsiTerminalDisplay(stdout);
|
const display = new AnsiTerminalDisplay(stdout);
|
||||||
|
@ -31,6 +48,25 @@ describe(AnsiTerminalDisplay, () => {
|
||||||
await display.setChar({ x: 0, y: 2 }, { ch: "e", fg: 0, bg: 0 });
|
await display.setChar({ x: 0, y: 2 }, { ch: "e", fg: 0, bg: 0 });
|
||||||
checkSequence(stdout, "![1;1Hab![1;4Hcd![3;1He");
|
checkSequence(stdout, "![1;1Hab![1;4Hcd![3;1He");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("changes colors only when needed", async () => {
|
||||||
|
const stdout = new Buffer();
|
||||||
|
const display = new AnsiTerminalDisplay(stdout);
|
||||||
|
display.forceSize({ w: 10, h: 1 });
|
||||||
|
await display.setupPalette([
|
||||||
|
{ r: 0.0, g: 0.0, b: 0.0 },
|
||||||
|
], AnsiColorMode.COLORS256);
|
||||||
|
await display.setChar({ x: 0, y: 0 }, { ch: "a", fg: 1, bg: 0 });
|
||||||
|
await display.setChar({ x: 1, y: 0 }, { ch: "b", fg: 1, bg: 0 });
|
||||||
|
await display.setChar({ x: 2, y: 0 }, { ch: "c", fg: 2, bg: 0 });
|
||||||
|
await display.setChar({ x: 3, y: 0 }, { ch: "d", fg: 0, bg: 2 });
|
||||||
|
await display.setChar({ x: 4, y: 0 }, { ch: "e", fg: 0, bg: 2 });
|
||||||
|
await display.setChar({ x: 5, y: 0 }, { ch: "f", fg: 0, bg: 3 });
|
||||||
|
checkSequence(
|
||||||
|
stdout,
|
||||||
|
"![38;5;1m![48;5;0m![1;1Hab![38;5;2mc![38;5;0m![48;5;2mde![48;5;3mf",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkSequence(buffer: Buffer, expected: string) {
|
function checkSequence(buffer: Buffer, expected: string) {
|
||||||
|
|
78
ansi.ts
78
ansi.ts
|
@ -1,6 +1,12 @@
|
||||||
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";
|
||||||
|
|
||||||
|
export enum AnsiColorMode {
|
||||||
|
AUTODETECT,
|
||||||
|
COLORS256,
|
||||||
|
TRUECOLOR,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANSI terminal display
|
* ANSI terminal display
|
||||||
*/
|
*/
|
||||||
|
@ -25,16 +31,36 @@ export class AnsiTerminalDisplay implements Display {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
async setupPalette(
|
||||||
// TODO handle not fully rgb compatible terminals
|
colors: readonly Color[],
|
||||||
const cr = (x: number) => Math.round(x * 255);
|
mode = AnsiColorMode.AUTODETECT,
|
||||||
this.palette_bg = colors.map((col) =>
|
): Promise<readonly Color[]> {
|
||||||
escape(`[48;2;${cr(col.r)};${cr(col.g)};${cr(col.b)}m`)
|
if (mode == AnsiColorMode.AUTODETECT) {
|
||||||
);
|
const colorterm = Deno.env.get("COLORTERM");
|
||||||
this.palette_fg = colors.map((col) =>
|
if (colorterm?.search(/truecolor|24bit/)) {
|
||||||
escape(`[38;2;${cr(col.r)};${cr(col.g)};${cr(col.b)}m`)
|
mode = AnsiColorMode.TRUECOLOR;
|
||||||
);
|
} else {
|
||||||
return colors;
|
mode = AnsiColorMode.COLORS256;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AnsiColorMode.TRUECOLOR) {
|
||||||
|
// True color is supported, use the request palette as-is
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
// True color not supported, fallback to 256-colors
|
||||||
|
const result = get256Colors();
|
||||||
|
this.palette_bg = result.map((_, idx) => escape(`[48;5;${idx}m`));
|
||||||
|
this.palette_fg = result.map((_, idx) => escape(`[38;5;${idx}m`));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
|
@ -88,4 +114,36 @@ function escape(sequence: string): Uint8Array {
|
||||||
return new Uint8Array([0x1B, ...new TextEncoder().encode(sequence)]);
|
return new Uint8Array([0x1B, ...new TextEncoder().encode(sequence)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function get256Colors(): readonly Color[] {
|
||||||
|
const result: Color[] = [
|
||||||
|
{ r: 0, g: 0, b: 0 },
|
||||||
|
{ r: 0.5, g: 0, b: 0 },
|
||||||
|
{ r: 0, g: 0.5, b: 0 },
|
||||||
|
{ r: 0.5, g: 0.5, b: 0 },
|
||||||
|
{ r: 0, g: 0, b: 0.5 },
|
||||||
|
{ r: 0.5, g: 0, b: 0.5 },
|
||||||
|
{ r: 0, g: 0.5, b: 0.5 },
|
||||||
|
{ r: 0.75, g: 0.75, b: 0.75 },
|
||||||
|
{ r: 0.5, g: 0.5, b: 0.5 },
|
||||||
|
{ r: 1, g: 0, b: 0 },
|
||||||
|
{ r: 0, g: 1, b: 0 },
|
||||||
|
{ r: 1, g: 1, b: 0 },
|
||||||
|
{ r: 0, g: 0, b: 1 },
|
||||||
|
{ r: 1, g: 0, b: 1 },
|
||||||
|
{ r: 0, g: 1, b: 1 },
|
||||||
|
{ r: 1, g: 1, b: 1 },
|
||||||
|
];
|
||||||
|
for (let r = 0; r < 6; r++) {
|
||||||
|
for (let g = 0; g < 6; g++) {
|
||||||
|
for (let b = 0; b < 6; b++) {
|
||||||
|
result.push({ r: r / 5, g: g / 5, b: b / 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let l = 0; l < 24; l++) {
|
||||||
|
result.push({ r: (l + 1) / 25, g: (l + 1) / 25, b: (l + 1) / 25 });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const CLEAR = escape("[2J");
|
const CLEAR = escape("[2J");
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
--allow-env --unstable
|
--unstable --allow-env=CWD,COLORTERM
|
18
ui.test.ts
18
ui.test.ts
|
@ -16,6 +16,24 @@ describe(TextUI, () => {
|
||||||
await ui.flush();
|
await ui.flush();
|
||||||
expect(display.last).toEqual({ ch: "x", bg: 0, fg: 1 });
|
expect(display.last).toEqual({ ch: "x", bg: 0, fg: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds the best match for a color in a limited palette", async () => {
|
||||||
|
const app_palette = [
|
||||||
|
{ r: 0.0, g: 0.0, b: 1.0 },
|
||||||
|
{ r: 0.0, g: 0.0, b: 0.0 },
|
||||||
|
];
|
||||||
|
const display_palette = [
|
||||||
|
{ r: 1.0, g: 1.0, b: 1.0 },
|
||||||
|
{ r: 0.1, g: 0.2, b: 0.8 },
|
||||||
|
{ r: 0.1, g: 0.2, b: 0.1 },
|
||||||
|
];
|
||||||
|
const display = new PaletteTestDisplay(display_palette);
|
||||||
|
const ui = new TextUI(display);
|
||||||
|
await ui.init(app_palette);
|
||||||
|
ui.drawing.color(0, 1).text("x", { x: 0, y: 0 });
|
||||||
|
await ui.flush();
|
||||||
|
expect(display.last).toEqual({ ch: "x", bg: 2, fg: 1 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class PaletteTestDisplay implements Display {
|
class PaletteTestDisplay implements Display {
|
||||||
|
|
71
ui.ts
71
ui.ts
|
@ -5,6 +5,7 @@ import {
|
||||||
Color,
|
Color,
|
||||||
PaletteMap,
|
PaletteMap,
|
||||||
} from "./base.ts";
|
} from "./base.ts";
|
||||||
|
import { cmp } from "./deps.ts";
|
||||||
import { Display } from "./display.ts";
|
import { Display } from "./display.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,14 +66,58 @@ export class TextUI {
|
||||||
palette: UIPalette,
|
palette: UIPalette,
|
||||||
): Promise<PaletteMap> {
|
): Promise<PaletteMap> {
|
||||||
// get the colors supported by display
|
// get the colors supported by display
|
||||||
const allcolors = palette
|
const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]);
|
||||||
.map((c): Color[] => Array.isArray(c) ? c : [c])
|
const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []);
|
||||||
.reduce((acc, val) => acc.concat(val), []);
|
const display_colors = await this.display.setupPalette(all_colors);
|
||||||
const supported = await this.display.setupPalette(allcolors);
|
|
||||||
|
|
||||||
// TODO find the best color mapping for each source color
|
// rank all supported colors by proximity to each app color
|
||||||
|
let ranked: {
|
||||||
|
color: Color;
|
||||||
|
idx: number;
|
||||||
|
penalty: number;
|
||||||
|
matches: { color: Color; idx: number; distance: number }[];
|
||||||
|
}[] = [];
|
||||||
|
app_colors.forEach((colors, idx) => {
|
||||||
|
colors.forEach((color, alt) => {
|
||||||
|
ranked.push({
|
||||||
|
color,
|
||||||
|
idx,
|
||||||
|
penalty: alt + 1,
|
||||||
|
matches: display_colors.map((display_color, didx) => {
|
||||||
|
return {
|
||||||
|
color: display_color,
|
||||||
|
idx: didx,
|
||||||
|
distance: colorDistance(color, display_color),
|
||||||
|
};
|
||||||
|
}).sort(cmp({ key: (info) => info.distance })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return palette.map((_, idx) => idx);
|
// TODO negatively score colors too much near previously chosen ones
|
||||||
|
|
||||||
|
// find the best color mapping for each source color
|
||||||
|
const result = palette.map(() => -1);
|
||||||
|
while (ranked.length > 0) {
|
||||||
|
ranked.sort(
|
||||||
|
cmp({ key: (info) => info.matches[0].distance * info.penalty }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const best = ranked[0];
|
||||||
|
const app_idx = best.idx;
|
||||||
|
const display_idx = best.matches[0].idx;
|
||||||
|
result[app_idx] = display_idx;
|
||||||
|
|
||||||
|
for (const color of ranked) {
|
||||||
|
color.matches = color.matches.filter((match) =>
|
||||||
|
match.idx !== display_idx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ranked = ranked.filter((color) =>
|
||||||
|
color.idx !== app_idx && color.matches.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,3 +132,17 @@ export class TextUI {
|
||||||
* array of accepted alternatives, with decreasing priority.
|
* array of accepted alternatives, with decreasing priority.
|
||||||
*/
|
*/
|
||||||
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;
|
export type UIPalette = ReadonlyArray<Color | ReadonlyArray<Color>>;
|
||||||
|
|
||||||
|
function colorDistance(e1: Color, e2: Color): number {
|
||||||
|
/*return (e2.r - e1.r) * (e2.r - e1.r) +
|
||||||
|
(e2.g - e1.g) * (e2.g - e1.g) +
|
||||||
|
(e2.b - e1.b) * (e2.b - e1.b);*/
|
||||||
|
const c = (x: number) => Math.round(x * 255);
|
||||||
|
const rmean = (c(e1.r) + c(e2.r)) / 2;
|
||||||
|
const r = c(e1.r) - c(e2.r);
|
||||||
|
const g = c(e1.g) - c(e2.g);
|
||||||
|
const b = c(e1.b) - c(e2.b);
|
||||||
|
return Math.sqrt(
|
||||||
|
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue