import { CharCode } from '../constants'; function adjustLight(color: number, amount: number) { const cc = color + amount; const c = amount < 0 ? (cc < 0 ? 0 : cc) : cc > 255 ? 255 : cc; return Math.round(c); } // TODO@d13 leaving as is for now, updating to the color library breaks our existing darkened colors export function darken(color: string, percentage: number) { return lighten(color, -percentage); } // TODO@d13 leaving as is for now, updating to the color library breaks our existing lightened colors export function lighten(color: string, percentage: number) { const rgba = toRgba(color); if (rgba == null) return color; const [r, g, b, a] = rgba; const amount = (255 * percentage) / 100; return `rgba(${adjustLight(r, amount)}, ${adjustLight(g, amount)}, ${adjustLight(b, amount)}, ${a})`; } export function opacity(color: string, percentage: number) { const rgba = Color.from(color); if (rgba == null) return color; return rgba.transparent(percentage / 100).toString(); } export function mix(color1: string, color2: string, percentage: number) { const rgba1 = Color.from(color1); const rgba2 = Color.from(color2); if (rgba1 == null || rgba2 == null) return color1; return rgba1.mix(rgba2, percentage / 100).toString(); } export function scale(value1: string, value2: string, steps: number): string[] { const colors = []; const color1 = Color.from(value1); const color2 = Color.from(value2); colors.push(color1.toString()); const range = steps - 1; for (let i = 1; i < range; i++) { const newColor = color1.mix(color2, i / range); colors.push(newColor.toString()); } colors.push(color2.toString()); return colors; } export function toRgba(color: string) { const result = parseColor(color); if (result == null) return null; return [result.rgba.r, result.rgba.g, result.rgba.b, result.rgba.a]; } function mixColors(col1: Color, col2: Color, factor: number): Color { const xyz0 = col1.rgba; const xyz1 = col2.rgba; return new Color( new RGBA( xyz0.r + factor * (xyz1.r - xyz0.r), xyz0.g + factor * (xyz1.g - xyz0.g), xyz0.b + factor * (xyz1.b - xyz0.b), xyz0.a + factor * (xyz1.a - xyz0.a), ), ); } const levelOfAccuracy = 1e-7; const maxAttempts = 20; export function luminance(baseColor: Color, lum: number): Color { if (lum === 0) { // return pure black return new Color(new RGBA(0, 0, 0, baseColor.rgba.a)); } if (lum === 1) { // return pure white return new Color(new RGBA(255, 255, 255, baseColor.rgba.a)); } // compute new color using... const currLum = baseColor.getRelativeLuminance(); let maxIter = maxAttempts; const test = (low: Color, high: Color): Color => { const mid = low.mix(high, 0.5); const lm = mid.getRelativeLuminance(); if (Math.abs(lum - lm) < levelOfAccuracy || !maxIter--) { // close enough return mid; } return lm > lum ? test(low, mid) : test(mid, high); }; const rgba = (currLum > lum ? test(Color.black, baseColor) : test(baseColor, Color.white)).rgba; return new Color(new RGBA(rgba.r, rgba.g, rgba.b, baseColor.rgba.a)); } // Iteration on VS Code's color utils // See: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/color.ts function roundFloat(number: number, decimalPoints: number): number { const decimal = Math.pow(10, decimalPoints); return Math.round(number * decimal) / decimal; } export class RGBA { _rgbaBrand: void = undefined; /** * Red: integer in [0-255] */ readonly r: number; /** * Green: integer in [0-255] */ readonly g: number; /** * Blue: integer in [0-255] */ readonly b: number; /** * Alpha: float in [0-1] */ readonly a: number; constructor(r: number, g: number, b: number, a: number = 1) { this.r = Math.min(255, Math.max(0, r)) | 0; this.g = Math.min(255, Math.max(0, g)) | 0; this.b = Math.min(255, Math.max(0, b)) | 0; this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); } static equals(a: RGBA, b: RGBA): boolean { return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; } } export class HSLA { _hslaBrand: void = undefined; /** * Hue: integer in [0, 360] */ readonly h: number; /** * Saturation: float in [0, 1] */ readonly s: number; /** * Luminosity: float in [0, 1] */ readonly l: number; /** * Alpha: float in [0, 1] */ readonly a: number; constructor(h: number, s: number, l: number, a: number) { this.h = Math.max(Math.min(360, h), 0) | 0; this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); this.l = roundFloat(Math.max(Math.min(1, l), 0), 3); this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); } static equals(a: HSLA, b: HSLA): boolean { return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a; } /** * Converts an RGB color value to HSL. Conversion formula * adapted from http://en.wikipedia.org/wiki/HSL_color_space. * Assumes r, g, and b are contained in the set [0, 255] and * returns h in the set [0, 360], s, and l in the set [0, 1]. */ static fromRGBA(rgba: RGBA): HSLA { const r = rgba.r / 255; const g = rgba.g / 255; const b = rgba.b / 255; const a = rgba.a; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0; let s = 0; const l = (min + max) / 2; const chroma = max - min; if (chroma > 0) { s = Math.min(l <= 0.5 ? chroma / (2 * l) : chroma / (2 - 2 * l), 1); switch (max) { case r: h = (g - b) / chroma + (g < b ? 6 : 0); break; case g: h = (b - r) / chroma + 2; break; case b: h = (r - g) / chroma + 4; break; } h *= 60; h = Math.round(h); } return new HSLA(h, s, l, a); } private static _hue2rgb(p: number, q: number, t: number): number { if (t < 0) { t += 1; } if (t > 1) { t -= 1; } if (t < 1 / 6) { return p + (q - p) * 6 * t; } if (t < 1 / 2) { return q; } if (t < 2 / 3) { return p + (q - p) * (2 / 3 - t) * 6; } return p; } /** * Converts an HSL color value to RGB. Conversion formula * adapted from http://en.wikipedia.org/wiki/HSL_color_space. * Assumes h in the set [0, 360] s, and l are contained in the set [0, 1] and * returns r, g, and b in the set [0, 255]. */ static toRGBA(hsla: HSLA): RGBA { const h = hsla.h / 360; const { s, l, a } = hsla; let r: number; let g: number; let b: number; if (s === 0) { r = g = b = l; // achromatic } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = HSLA._hue2rgb(p, q, h + 1 / 3); g = HSLA._hue2rgb(p, q, h); b = HSLA._hue2rgb(p, q, h - 1 / 3); } return new RGBA(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a); } } export class HSVA { _hsvaBrand: void = undefined; /** * Hue: integer in [0, 360] */ readonly h: number; /** * Saturation: float in [0, 1] */ readonly s: number; /** * Value: float in [0, 1] */ readonly v: number; /** * Alpha: float in [0, 1] */ readonly a: number; constructor(h: number, s: number, v: number, a: number) { this.h = Math.max(Math.min(360, h), 0) | 0; this.s = roundFloat(Math.max(Math.min(1, s), 0), 3); this.v = roundFloat(Math.max(Math.min(1, v), 0), 3); this.a = roundFloat(Math.max(Math.min(1, a), 0), 3); } static equals(a: HSVA, b: HSVA): boolean { return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a; } // from http://www.rapidtables.com/convert/color/rgb-to-hsv.htm static fromRGBA(rgba: RGBA): HSVA { const r = rgba.r / 255; const g = rgba.g / 255; const b = rgba.b / 255; const cmax = Math.max(r, g, b); const cmin = Math.min(r, g, b); const delta = cmax - cmin; const s = cmax === 0 ? 0 : delta / cmax; let m: number; if (delta === 0) { m = 0; } else if (cmax === r) { m = ((((g - b) / delta) % 6) + 6) % 6; } else if (cmax === g) { m = (b - r) / delta + 2; } else { m = (r - g) / delta + 4; } return new HSVA(Math.round(m * 60), s, cmax, rgba.a); } // from http://www.rapidtables.com/convert/color/hsv-to-rgb.htm static toRGBA(hsva: HSVA): RGBA { const { h, s, v, a } = hsva; const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; let [r, g, b] = [0, 0, 0]; if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; } else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else if (h <= 360) { r = c; b = x; } r = Math.round((r + m) * 255); g = Math.round((g + m) * 255); b = Math.round((b + m) * 255); return new RGBA(r, g, b, a); } } export class Color { static from(value: string | Color): Color { if (value instanceof Color) return value; return parseColor(value) || Color.red; } static fromCssVariable(variable: string, css: { getPropertyValue(property: string): string }): Color { return parseColor(css.getPropertyValue(variable).trim()) || Color.red; } static fromHex(hex: string): Color { return parseHexColor(hex) || Color.red; } static equals(a: Color | null, b: Color | null): boolean { if (!a && !b) { return true; } if (!a || !b) { return false; } return a.equals(b); } readonly rgba: RGBA; private _hsla?: HSLA; get hsla(): HSLA { if (this._hsla) { return this._hsla; } return HSLA.fromRGBA(this.rgba); } private _hsva?: HSVA; get hsva(): HSVA { if (this._hsva) { return this._hsva; } return HSVA.fromRGBA(this.rgba); } constructor(arg: RGBA | HSLA | HSVA) { if (!arg) { throw new Error('Color needs a value'); } else if (arg instanceof RGBA) { this.rgba = arg; } else if (arg instanceof HSLA) { this._hsla = arg; this.rgba = HSLA.toRGBA(arg); } else if (arg instanceof HSVA) { this._hsva = arg; this.rgba = HSVA.toRGBA(arg); } else { throw new Error('Invalid color ctor argument'); } } equals(other: Color | null): boolean { if (other == null) return false; return ( Boolean(other) && RGBA.equals(this.rgba, other.rgba) && HSLA.equals(this.hsla, other.hsla) && HSVA.equals(this.hsva, other.hsva) ); } /** * http://www.w3.org/TR/WCAG20/#relativeluminancedef * Returns the number in the set [0, 1]. O => Darkest Black. 1 => Lightest white. */ getRelativeLuminance(): number { const R = Color._relativeLuminanceForComponent(this.rgba.r); const G = Color._relativeLuminanceForComponent(this.rgba.g); const B = Color._relativeLuminanceForComponent(this.rgba.b); const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B; return roundFloat(luminance, 4); } private static _relativeLuminanceForComponent(color: number): number { const c = color / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } luminance(lum: number): Color { return luminance(this, lum); } /** * http://www.w3.org/TR/WCAG20/#contrast-ratiodef * Returns the contrast ration number in the set [1, 21]. */ getContrastRatio(another: Color): number { const lum1 = this.getRelativeLuminance(); const lum2 = another.getRelativeLuminance(); return lum1 > lum2 ? (lum1 + 0.05) / (lum2 + 0.05) : (lum2 + 0.05) / (lum1 + 0.05); } /** * http://24ways.org/2010/calculating-color-contrast * Return 'true' if darker color otherwise 'false' */ isDarker(): boolean { const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; return yiq < 128; } /** * http://24ways.org/2010/calculating-color-contrast * Return 'true' if lighter color otherwise 'false' */ isLighter(): boolean { const yiq = (this.rgba.r * 299 + this.rgba.g * 587 + this.rgba.b * 114) / 1000; return yiq >= 128; } isLighterThan(another: Color): boolean { const lum1 = this.getRelativeLuminance(); const lum2 = another.getRelativeLuminance(); return lum1 > lum2; } isDarkerThan(another: Color): boolean { const lum1 = this.getRelativeLuminance(); const lum2 = another.getRelativeLuminance(); return lum1 < lum2; } lighten(factor: number): Color { return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l + this.hsla.l * factor, this.hsla.a)); } darken(factor: number): Color { return new Color(new HSLA(this.hsla.h, this.hsla.s, this.hsla.l - this.hsla.l * factor, this.hsla.a)); } transparent(factor: number): Color { const { r, g, b, a } = this.rgba; return new Color(new RGBA(r, g, b, a * factor)); } isTransparent(): boolean { return this.rgba.a === 0; } isOpaque(): boolean { return this.rgba.a === 1; } opposite(): Color { return new Color(new RGBA(255 - this.rgba.r, 255 - this.rgba.g, 255 - this.rgba.b, this.rgba.a)); } blend(c: Color): Color { const rgba = c.rgba; // Convert to 0..1 opacity const thisA = this.rgba.a; const colorA = rgba.a; const a = thisA + colorA * (1 - thisA); if (a < 1e-6) { return Color.transparent; } const r = (this.rgba.r * thisA) / a + (rgba.r * colorA * (1 - thisA)) / a; const g = (this.rgba.g * thisA) / a + (rgba.g * colorA * (1 - thisA)) / a; const b = (this.rgba.b * thisA) / a + (rgba.b * colorA * (1 - thisA)) / a; return new Color(new RGBA(r, g, b, a)); } mix(color: Color, factor: number) { return mixColors(this, color, factor); } makeOpaque(opaqueBackground: Color): Color { if (this.isOpaque() || opaqueBackground.rgba.a !== 1) { // only allow to blend onto a non-opaque color onto a opaque color return this; } const { r, g, b, a } = this.rgba; // https://stackoverflow.com/questions/12228548/finding-equivalent-color-with-opacity return new Color( new RGBA( opaqueBackground.rgba.r - a * (opaqueBackground.rgba.r - r), opaqueBackground.rgba.g - a * (opaqueBackground.rgba.g - g), opaqueBackground.rgba.b - a * (opaqueBackground.rgba.b - b), 1, ), ); } flatten(...backgrounds: Color[]): Color { const background = backgrounds.reduceRight((accumulator, color) => { return Color._flatten(color, accumulator); }); return Color._flatten(this, background); } private static _flatten(foreground: Color, background: Color) { const backgroundAlpha = 1 - foreground.rgba.a; return new Color( new RGBA( backgroundAlpha * background.rgba.r + foreground.rgba.a * foreground.rgba.r, backgroundAlpha * background.rgba.g + foreground.rgba.a * foreground.rgba.g, backgroundAlpha * background.rgba.b + foreground.rgba.a * foreground.rgba.b, ), ); } private _toString?: string; toString(): string { if (!this._toString) { this._toString = format(this); } return this._toString; } static getLighterColor(of: Color, relative: Color, factor?: number): Color { if (of.isLighterThan(relative)) { return of; } factor = factor ? factor : 0.5; const lum1 = of.getRelativeLuminance(); const lum2 = relative.getRelativeLuminance(); factor = (factor * (lum2 - lum1)) / lum2; return of.lighten(factor); } static getDarkerColor(of: Color, relative: Color, factor?: number): Color { if (of.isDarkerThan(relative)) { return of; } factor = factor ? factor : 0.5; const lum1 = of.getRelativeLuminance(); const lum2 = relative.getRelativeLuminance(); factor = (factor * (lum1 - lum2)) / lum1; return of.darken(factor); } static readonly white = new Color(new RGBA(255, 255, 255, 1)); static readonly black = new Color(new RGBA(0, 0, 0, 1)); static readonly red = new Color(new RGBA(255, 0, 0, 1)); static readonly blue = new Color(new RGBA(0, 0, 255, 1)); static readonly green = new Color(new RGBA(0, 255, 0, 1)); static readonly cyan = new Color(new RGBA(0, 255, 255, 1)); static readonly lightgrey = new Color(new RGBA(211, 211, 211, 1)); static readonly transparent = new Color(new RGBA(0, 0, 0, 0)); } export function formatRGB(color: Color): string { if (color.rgba.a === 1) { return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})`; } return formatRGBA(color); } export function formatRGBA(color: Color): string { return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${Number(color.rgba.a.toFixed(2))})`; } export function formatHSL(color: Color): string { if (color.hsla.a === 1) { return `hsl(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed(2)}%)`; } return formatHSLA(color); } export function formatHSLA(color: Color): string { return `hsla(${color.hsla.h}, ${(color.hsla.s * 100).toFixed(2)}%, ${(color.hsla.l * 100).toFixed( 2, )}%, ${color.hsla.a.toFixed(2)})`; } function _toTwoDigitHex(n: number): string { const r = n.toString(16); return r.length !== 2 ? `0${r}` : r; } /** * Formats the color as #RRGGBB */ export function formatHex(color: Color): string { return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex(color.rgba.b)}`; } /** * Formats the color as #RRGGBBAA * If 'compact' is set, colors without transparancy will be printed as #RRGGBB */ export function formatHexA(color: Color, compact = false): string { if (compact && color.rgba.a === 1) { return formatHex(color); } return `#${_toTwoDigitHex(color.rgba.r)}${_toTwoDigitHex(color.rgba.g)}${_toTwoDigitHex( color.rgba.b, )}${_toTwoDigitHex(Math.round(color.rgba.a * 255))}`; } /** * The default format will use HEX if opaque and RGBA otherwise. */ export function format(color: Color): string { if (color.isOpaque()) { return formatHex(color); } return formatRGBA(color); } const cssColorRegex = /^((?:rgb|hsl)a?)\((-?\d+%?)[,\s]+(-?\d+%?)[,\s]+(-?\d+%?)[,\s]*(-?[\d.]+%?)?\)$/i; export function parseColor(value: string): Color | null { const length = value.length; // Invalid color if (length === 0) { return null; } // Begin with a # if (value.charCodeAt(0) === CharCode.Hash) { return parseHexColor(value); } const result = cssColorRegex.exec(value); if (result == null) { return null; } const mode = result[1]; let colors: number[]; switch (mode) { case 'rgb': case 'hsl': colors = [parseInt(result[2], 10), parseInt(result[3], 10), parseInt(result[4], 10), 1]; break; case 'rgba': case 'hsla': colors = [parseInt(result[2], 10), parseInt(result[3], 10), parseInt(result[4], 10), parseFloat(result[5])]; break; default: return null; } switch (mode) { case 'rgb': case 'rgba': return new Color(new RGBA(colors[0], colors[1], colors[2], colors[3])); case 'hsl': case 'hsla': return new Color(new HSLA(colors[0], colors[1], colors[2], colors[3])); } return Color.red; } /** * Converts a Hex color value to a Color. * returns r, g, and b are contained in the set [0, 255] * @param hex string (#RGB, #RGBA, #RRGGBB or #RRGGBBAA). */ export function parseHexColor(hex: string): Color | null { hex = hex.trim(); const length = hex.length; if (length === 0) { // Invalid color return null; } if (hex.charCodeAt(0) !== CharCode.Hash) { // Does not begin with a # return null; } if (length === 7) { // #RRGGBB format const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); return new Color(new RGBA(r, g, b, 1)); } if (length === 9) { // #RRGGBBAA format const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); return new Color(new RGBA(r, g, b, a / 255)); } if (length === 4) { // #RGB format const r = _parseHexDigit(hex.charCodeAt(1)); const g = _parseHexDigit(hex.charCodeAt(2)); const b = _parseHexDigit(hex.charCodeAt(3)); return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); } if (length === 5) { // #RGBA format const r = _parseHexDigit(hex.charCodeAt(1)); const g = _parseHexDigit(hex.charCodeAt(2)); const b = _parseHexDigit(hex.charCodeAt(3)); const a = _parseHexDigit(hex.charCodeAt(4)); return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); } // Invalid color return null; } function _parseHexDigit(charCode: CharCode): number { switch (charCode) { case CharCode.Digit0: return 0; case CharCode.Digit1: return 1; case CharCode.Digit2: return 2; case CharCode.Digit3: return 3; case CharCode.Digit4: return 4; case CharCode.Digit5: return 5; case CharCode.Digit6: return 6; case CharCode.Digit7: return 7; case CharCode.Digit8: return 8; case CharCode.Digit9: return 9; case CharCode.a: return 10; case CharCode.A: return 10; case CharCode.b: return 11; case CharCode.B: return 11; case CharCode.c: return 12; case CharCode.C: return 12; case CharCode.d: return 13; case CharCode.D: return 13; case CharCode.e: return 14; case CharCode.E: return 14; case CharCode.f: return 15; case CharCode.F: return 15; } return 0; }