diff --git a/.changeset/plenty-ways-carry.md b/.changeset/plenty-ways-carry.md new file mode 100644 index 0000000..c78c060 --- /dev/null +++ b/.changeset/plenty-ways-carry.md @@ -0,0 +1,7 @@ +--- +'kolorist': minor +--- + +Add support for 24bit TrueColor detection. + +This is supported in nearly every modern terminals these days. The exception to that is the built in Terminal app on macOS and CI systems. TrueColor values are automatically converted to Ansi 256 when TrueColor isn't supported, but Ansi 256 is. The only case where I found that in practice was with Terminal.app on macOS which only supports Ansi 256. diff --git a/src/index.test.ts b/src/index.test.ts index ac8240d..6a80432 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -41,7 +41,18 @@ describe('colors', () => { it('should print demo', () => { const strs = Object.keys(k) - .filter(key => !['options', 'ansi256', 'ansi256Bg', 'link', 'SupportLevel'].includes(key)) + .filter( + key => + ![ + 'options', + 'ansi256', + 'ansi256Bg', + 'link', + 'SupportLevel', + 'trueColor', + 'trueColorBg', + ].includes(key) + ) .map(x => (k as any)[x]('foobar')); console.log(columnize(strs, 16)); @@ -75,6 +86,45 @@ describe('colors', () => { }); }); + describe('TrueColor 24bit', () => { + beforeEach(() => { + k.options.supportLevel = k.SupportLevel.trueColor; + }); + + it('should print foreground', () => { + const str = k.trueColor(134, 239, 172)('foo'); + console.log(str); + }); + + it('should print background', () => { + const str = k.trueColorBg(134, 239, 172)('foo'); + console.log(str); + }); + + it('should mix with modifiers', () => { + console.log(k.dim(k.trueColor(134, 239, 172)('foo'))); + console.log(k.dim(k.trueColorBg(134, 239, 172)(k.black('foo')))); + }); + + it('should be stripped', () => { + t.equal(k.stripColors(k.trueColor(134, 239, 172)('foo')), 'foo'); + }); + + it('should be ignored if no terminal support', () => { + k.options.supportLevel = k.SupportLevel.ansi; + t.equal( + JSON.stringify(k.trueColor(134, 239, 172)('foo')), + JSON.stringify('foo') + ); + }); + + it('should convert color space to ansi256 if possible', () => { + k.options.supportLevel = k.SupportLevel.ansi256; + t.equal(k.trueColor(134, 239, 172)('foo'), k.ansi256(157)('foo')); + t.equal(k.trueColorBg(134, 239, 172)('foo'), k.ansi256Bg(157)('foo')); + }); + }); + it('should toggle enabled or disabled', () => { k.options.enabled = true; t.equal(k.cyan('foo'), '\u001b[36mfoo\u001b[39m'); diff --git a/src/index.ts b/src/index.ts index 8321bd7..b7119e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export const enum SupportLevel { none, ansi, ansi256, + trueColor, } /** @@ -22,10 +23,15 @@ export const enum SupportLevel { let supportLevel: SupportLevel = SupportLevel.none; if (globalVar.process && globalVar.process.env && globalVar.process.stdout) { - const { FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM } = globalVar.process.env; + const { FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM, COLORTERM } = + globalVar.process.env; if (NODE_DISABLE_COLORS || NO_COLOR || FORCE_COLOR === '0') { enabled = false; - } else if (FORCE_COLOR === '1' || FORCE_COLOR === '2' || FORCE_COLOR === '3') { + } else if ( + FORCE_COLOR === '1' || + FORCE_COLOR === '2' || + FORCE_COLOR === '3' + ) { enabled = true; } else if (TERM === 'dumb') { enabled = false; @@ -47,10 +53,19 @@ if (globalVar.process && globalVar.process.env && globalVar.process.stdout) { } if (enabled) { - supportLevel = - TERM && TERM.endsWith('-256color') - ? SupportLevel.ansi256 - : SupportLevel.ansi; + // Windows supports 24bit True Colors since Windows 10 revision #14931, + // see https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ + if (process.platform === 'win32') { + supportLevel = SupportLevel.trueColor; + } else { + if (COLORTERM && (COLORTERM === 'truecolor' || COLORTERM === '24bit')) { + supportLevel = SupportLevel.trueColor; + } else if (TERM && (TERM.endsWith('-256color') || TERM.endsWith('256'))) { + supportLevel = SupportLevel.ansi256; + } else { + supportLevel = SupportLevel.ansi; + } + } } } @@ -75,6 +90,33 @@ function kolorist( }; } +// Lower colors into 256 color space +// Taken from https://github.com/Qix-/color-convert/blob/3f0e0d4e92e235796ccb17f6e85c72094a651f49/conversions.js +// which is MIT licensed and copyright by Heather Arthur and Josh Junon +function rgbToAnsi256(r: number, g: number, b: number): number { + // We use the extended greyscale palette here, with the exception of + // black and white. normal palette only has 4 greyscale shades. + if (r >> 4 === g >> 4 && g >> 4 === b >> 4) { + if (r < 8) { + return 16; + } + + if (r > 248) { + return 231; + } + + return Math.round(((r - 8) / 247) * 24) + 232; + } + + const ansi = + 16 + + 36 * Math.round((r / 255) * 5) + + 6 * Math.round((g / 255) * 5) + + Math.round((b / 255) * 5); + + return ansi; +} + export function stripColors(str: string | number) { return ('' + str) .replace(/\x1b\[[0-9;]+m/g, '') @@ -135,6 +177,18 @@ export const ansi256 = (n: number) => export const ansi256Bg = (n: number) => kolorist('48;5;' + n, 0, SupportLevel.ansi256); +// TrueColor 24bit support +export const trueColor = (r: number, g: number, b: number) => { + return options.supportLevel === SupportLevel.ansi256 + ? ansi256(rgbToAnsi256(r, g, b)) + : kolorist(`38;2;${r};${g};${b}`, 0, SupportLevel.trueColor); +}; +export const trueColorBg = (r: number, g: number, b: number) => { + return options.supportLevel === SupportLevel.ansi256 + ? ansi256Bg(rgbToAnsi256(r, g, b)) + : kolorist(`48;2;${r};${g};${b}`, 0, SupportLevel.trueColor); +}; + // Links const OSC = '\u001B]'; const BEL = '\u0007';