From 731f601cdb1b3dce28935174e7a8236403747616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Sun, 27 Jun 2021 23:11:49 +0200 Subject: [PATCH] Add 256 color support --- ansi.test.ts | 42 ++++++++++++++++++++++++-- ansi.ts | 78 +++++++++++++++++++++++++++++++++++++++++------- config/run.flags | 2 +- ui.test.ts | 18 +++++++++++ ui.ts | 71 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 191 insertions(+), 20 deletions(-) diff --git a/ansi.test.ts b/ansi.test.ts index 1ffd1a7..c7d77de 100644 --- a/ansi.test.ts +++ b/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"; describe(AnsiTerminalDisplay, () => { @@ -9,17 +9,34 @@ describe(AnsiTerminalDisplay, () => { checkSequence(stdout, "![2J"); }); - it("writes colored characters", async () => { + it("writes truecolor 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 }, - ]); + ], AnsiColorMode.TRUECOLOR); 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$"); }); + 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 () => { const stdout = new Buffer(); const display = new AnsiTerminalDisplay(stdout); @@ -31,6 +48,25 @@ describe(AnsiTerminalDisplay, () => { await display.setChar({ x: 0, y: 2 }, { ch: "e", fg: 0, bg: 0 }); 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) { diff --git a/ansi.ts b/ansi.ts index b17226d..74d94ce 100644 --- a/ansi.ts +++ b/ansi.ts @@ -1,6 +1,12 @@ import { BufferLocation, BufferSize, Char, Color } from "./base.ts"; import { Display } from "./display.ts"; +export enum AnsiColorMode { + AUTODETECT, + COLORS256, + TRUECOLOR, +} + /** * ANSI terminal display */ @@ -25,16 +31,36 @@ export class AnsiTerminalDisplay implements Display { }; } - async setupPalette(colors: readonly Color[]): Promise { - // 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; + async setupPalette( + colors: readonly Color[], + mode = AnsiColorMode.AUTODETECT, + ): Promise { + if (mode == AnsiColorMode.AUTODETECT) { + const colorterm = Deno.env.get("COLORTERM"); + if (colorterm?.search(/truecolor|24bit/)) { + mode = AnsiColorMode.TRUECOLOR; + } else { + 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 { @@ -88,4 +114,36 @@ function escape(sequence: string): Uint8Array { 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"); diff --git a/config/run.flags b/config/run.flags index b1c44e5..730830a 100644 --- a/config/run.flags +++ b/config/run.flags @@ -1 +1 @@ ---allow-env --unstable \ No newline at end of file +--unstable --allow-env=CWD,COLORTERM \ No newline at end of file diff --git a/ui.test.ts b/ui.test.ts index 3d4c394..88970bd 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -16,6 +16,24 @@ describe(TextUI, () => { await ui.flush(); 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 { diff --git a/ui.ts b/ui.ts index 4c94092..d03dc63 100644 --- a/ui.ts +++ b/ui.ts @@ -5,6 +5,7 @@ import { Color, PaletteMap, } from "./base.ts"; +import { cmp } from "./deps.ts"; import { Display } from "./display.ts"; /** @@ -65,14 +66,58 @@ export class TextUI { palette: UIPalette, ): Promise { // 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); + const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]); + const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []); + const display_colors = await this.display.setupPalette(all_colors); - // 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. */ export type UIPalette = ReadonlyArray>; + +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), + ); +}