import chalk from 'chalk'; import { Env, TypedObject } from '@travetto/runtime'; type TemplatePrim = number | string | bigint | boolean | RegExp; type Color = `#${string}`; export type TermStyleInput = Color | { text: Color, background?: Color, inverse?: boolean, bold?: boolean, italic?: boolean, underline?: boolean }; type TermStylePairInput = [dark: TermStyleInput, light?: TermStyleInput] | readonly [dark: TermStyleInput, light?: TermStyleInput]; export type TermStyleFn = (input: TemplatePrim) => string; export type TermStyledTemplate = (values: TemplateStringsArray, ...keys: (Partial> | string)[]) => string; export type ColorLevel = 0 | 1 | 2 | 3; const ANSI_16_RGB: [number, number, number][] = [ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192], [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255] ]; const toLinear = (v: number): number => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; export class StyleUtil { /** Compute RGB values for ANSI 256 color code */ static computeRGBForAnsi256(code: number): [number, number, number] { if (code < 16) { return ANSI_16_RGB[code]; } else if (code <= 231) { const cubeIdx = code - 16; const levels = [0, 95, 135, 175, 215, 255]; const ri = Math.floor(cubeIdx / 36); const gi = Math.floor((cubeIdx % 36) / 6); const bi = cubeIdx % 6; return [levels[ri], levels[gi], levels[bi]]; } else { const gray = 8 + (code - 232) * 10; return [gray, gray, gray]; } } /** Compute Luminosity for ANSI 256 color code */ static computeAnsi256Luminosity(code: number): number { const [r, g, b] = this.computeRGBForAnsi256(code); return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } static #scheme: { key: string, dark: boolean } = { key: '', dark: true }; /** * Create text render function from style input using current color levels */ static getStyle(input: TermStyleInput): TermStyleFn { if (typeof input === 'string') { return chalk.hex(input); } else { let style = chalk; for (const key of TypedObject.keys(input)) { switch (key) { case 'text': style = style.hex(input[key]!); break; case 'background': style = style.bgHex(input[key]!); break; default: style = (input[key] ? style[key] : style); break; } } return style; } } /** * Read foreground/background color if env var is set */ static isBackgroundDark(): boolean { const key = Env.COLORFGBG.value ?? ''; if (this.#scheme.key === key) { return this.#scheme.dark; } const [, bg = '0'] = key.split(';'); const dark = this.computeAnsi256Luminosity(+bg) < 0.4; Object.assign(this.#scheme, { key, dark }); return dark; } /** * Create renderer from input source */ static getThemedStyle(input: TermStylePairInput): TermStyleFn { const [dark, light] = input; const isDark = this.isBackgroundDark(); return isDark ? this.getStyle(dark) : this.getStyle(light ?? dark); } /** * Is styling currently enabled */ static get enabled(): boolean { return chalk.level > 0; } /** * Build style palette, with support for background theme awareness */ static getPalette(input: Record): Record { return TypedObject.fromEntries( TypedObject.entries(input).map(([key, value]) => [key, this.getThemedStyle(value)])); } /** * Make a template function based on the input set */ static getTemplate(input: Record): TermStyledTemplate { const palette = this.getPalette(input); return (values: TemplateStringsArray, ...keys: (Partial> | string)[]) => { if (keys.length === 0) { return values[0]; } else { const out = keys.map((item, i) => { let final = item; if (typeof item !== 'string') { const subKeys = TypedObject.keys(item); if (subKeys.length !== 1) { throw new Error('Invalid template variable, one and only one key should be specified'); } const [key] = subKeys; const value = item[key]!; final = value === undefined ? '' : palette[key](value)!; } return `${values[i] ?? ''}${final ?? ''}`; }); if (values.length > keys.length) { out.push(values.at(-1)!); } return out.join(''); } }; } }