diff --git a/src/browser/Color.test.ts b/src/browser/Color.test.ts index 44cd52f32..a8e4fa03d 100644 --- a/src/browser/Color.test.ts +++ b/src/browser/Color.test.ts @@ -4,7 +4,7 @@ */ import { assert } from 'chai'; -import { blend, fromCss, toPaddedHex, toCss, toRgba } from 'browser/Color'; +import { blend, fromCss, toPaddedHex, toCss, toRgba, rgbRelativeLuminance, contrastRatio } from 'browser/Color'; describe('Color', () => { describe('blend', () => { @@ -135,4 +135,26 @@ describe('Color', () => { assert.equal(toRgba(0xff, 0xff, 0xff, 0xff), 0xffffffff); }); }); + describe('rgbRelativeLuminance', () => { + it('should calculate the relative luminance of the color', () => { + assert.equal(rgbRelativeLuminance(0x000000), 0); + + // TODO: Fill in tests + + assert.equal(rgbRelativeLuminance(0xFFFFFF), 1); + }); + }); + describe('contrastRatio', () => { + it('should calculate the relative luminance of the color', () => { + assert.equal(contrastRatio(0, 0), 1); + + // TODO: Fill in tests + + assert.equal(contrastRatio(0, 1), 21); + }); + it('should work regardless of the parameter order', () => { + assert.equal(contrastRatio(0, 1), 21); + assert.equal(contrastRatio(1, 0), 21); + }); + }); }); diff --git a/src/browser/Color.ts b/src/browser/Color.ts index a8ce2d16a..4063e4c19 100644 --- a/src/browser/Color.ts +++ b/src/browser/Color.ts @@ -47,3 +47,102 @@ export function toRgba(r: number, g: number, b: number, a: number = 0xFF): numbe // >>> 0 forces an unsigned int return (r << 24 | g << 16 | b << 8 | a) >>> 0; } + +/** + * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio + * between two colors. + * @param rgb The color to use. + * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ +export function rgbRelativeLuminance(rgb: number): number { + return rgbRelativeLuminance2( + (rgb >> 16) & 0xFF, + (rgb >> 8 ) & 0xFF, + (rgb ) & 0xFF); +} + +export function rgbRelativeLuminance2(r: number, g: number, b: number): number { + const rs = r / 255; + const gs = g / 255; + const bs = b / 255; + const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4); + const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4); + const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4); + return rr * 0.2126 + rg * 0.7152 + rb * 0.0722; +} + +/** + * Gets the contrast ratio between two relative luminance values. + * @param l1 The first relative luminance. + * @param l2 The first relative luminance. + * + * // TODO: Is this link right? + * @see https://www.w3.org/TR/WCAG20/#contrastratio + */ +export function contrastRatio(l1: number, l2: number): number { + if (l1 < l2) { + return (l2 + 0.05) / (l1 + 0.05); + } + return (l1 + 0.05) / (l2 + 0.05); +} + +// TODO: Cache [bg][fg]: result, should probably be owned by ColorManager? + +export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined { + const bgL = rgbRelativeLuminance(bg.rgba >> 8); + const fgL = rgbRelativeLuminance(fg.rgba >> 8); + const cr = contrastRatio(bgL, fgL); + if (cr < ratio) { + if (fgL < bgL) { + return reduceLuminance(bg, fg, ratio); + } + return increaseLuminance(bg, fg, ratio); + } + return undefined; +} + +export function reduceLuminance(bg: IColor, fg: IColor, ratio: number): IColor { + // This is a naive but fast approach to reducing luminance as converting to + // HSL and back is expensive + const bgR = (bg.rgba >> 24) & 0xFF; + const bgG = (bg.rgba >> 16) & 0xFF; + const bgB = (bg.rgba >> 8) & 0xFF; + let fgR = (fg.rgba >> 24) & 0xFF; + let fgG = (fg.rgba >> 16) & 0xFF; + let fgB = (fg.rgba >> 8) & 0xFF; + let cr = contrastRatio(rgbRelativeLuminance2(fgR, fgB, fgG), rgbRelativeLuminance2(bgR, bgG, bgB)); + while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) { + // Increase by 10% (ceil) until the ratio is hit + fgR -= Math.max(0, Math.ceil(fgR * 0.1)); + fgG -= Math.max(0, Math.ceil(fgG * 0.1)); + fgB -= Math.max(0, Math.ceil(fgB * 0.1)); + cr = contrastRatio(rgbRelativeLuminance2(fgR, fgB, fgG), rgbRelativeLuminance2(bgR, bgG, bgB)); + } + return { + css: toCss(fgR, fgG, fgB), + rgba: toRgba(fgR, fgG, fgB) + }; +} + +export function increaseLuminance(bg: IColor, fg: IColor, ratio: number): IColor { + // This is a naive but fast approach to increasing luminance as converting to + // HSL and back is expensive + const bgR = (bg.rgba >> 24) & 0xFF; + const bgG = (bg.rgba >> 16) & 0xFF; + const bgB = (bg.rgba >> 8) & 0xFF; + let fgR = (fg.rgba >> 24) & 0xFF; + let fgG = (fg.rgba >> 16) & 0xFF; + let fgB = (fg.rgba >> 8) & 0xFF; + let cr = contrastRatio(rgbRelativeLuminance2(fgR, fgB, fgG), rgbRelativeLuminance2(bgR, bgG, bgB)); + while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) { + // Increase by 10% until the ratio is hit + fgR = Math.min(0xFF, fgR + Math.floor((255 - fgR) * 0.1)); + fgG = Math.min(0xFF, fgG + Math.floor((255 - fgG) * 0.1)); + fgB = Math.min(0xFF, fgB + Math.floor((255 - fgB) * 0.1)); + cr = contrastRatio(rgbRelativeLuminance2(fgR, fgB, fgG), rgbRelativeLuminance2(bgR, bgG, bgB)); + } + return { + css: toCss(fgR, fgG, fgB), + rgba: toRgba(fgR, fgG, fgB) + }; +} diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index e8fc85f66..23c5a3a19 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -79,7 +79,7 @@ export class DomRenderer extends Disposable implements IRenderer { this._updateDimensions(); this._injectCss(); - this._rowFactory = new DomRendererRowFactory(document, this._optionsService); + this._rowFactory = new DomRendererRowFactory(document, this._optionsService, this._colors); this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); this._screenElement.appendChild(this._rowContainer); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index b76d644d6..bb9d97b64 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -19,7 +19,7 @@ describe('DomRendererRowFactory', () => { beforeEach(() => { dom = new jsdom.JSDOM(''); - rowFactory = new DomRendererRowFactory(dom.window.document, new MockOptionsService({ drawBoldTextInBrightColors: true })); + rowFactory = new DomRendererRowFactory(dom.window.document, new MockOptionsService({ drawBoldTextInBrightColors: true }), {} as any); lineData = createEmptyLineData(2); }); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 3e3b5df0d..2c206f33d 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -6,9 +6,11 @@ import { IBufferLine } from 'common/Types'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; -import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; +import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { IOptionsService } from 'common/services/Services'; +import { rgbRelativeLuminance, fromCss, contrastRatio, ensureContrastRatio } from 'browser/Color'; +import { IColorSet, IColor } from 'browser/Types'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; @@ -24,11 +26,16 @@ export class DomRendererRowFactory { private _workCell: CellData = new CellData(); constructor( - private _document: Document, - private _optionsService: IOptionsService + private readonly _document: Document, + private readonly _optionsService: IOptionsService, + private _colors: IColorSet ) { } + public setColors(colors: IColorSet): void { + this._colors = colors; + } + public createRow(lineData: IBufferLine, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cursorBlink: boolean, cellWidth: number, cols: number): DocumentFragment { const fragment = this._document.createDocumentFragment(); @@ -97,36 +104,75 @@ export class DomRendererRowFactory { charElement.textContent = this._workCell.getChars() || WHITESPACE_CELL_CHAR; - const swapColor = this._workCell.isInverse(); - - // fg - if (this._workCell.isFgRGB()) { - let style = charElement.getAttribute('style') || ''; - style += `${swapColor ? 'background-' : ''}color:rgb(${(AttributeData.toColorRGB(this._workCell.getFgColor())).join(',')});`; - charElement.setAttribute('style', style); - } else if (this._workCell.isFgPalette()) { - let fg = this._workCell.getFgColor(); - if (this._workCell.isBold() && fg < 8 && !swapColor && this._optionsService.options.drawBoldTextInBrightColors) { - fg += 8; - } - charElement.classList.add(`xterm-${swapColor ? 'b' : 'f'}g-${fg}`); - } else if (swapColor) { - charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); + let fg = this._workCell.getFgColor(); + let fgColorMode = this._workCell.getFgColorMode(); + let bg = this._workCell.getBgColor(); + let bgColorMode = this._workCell.getBgColorMode(); + const isInverse = !!this._workCell.isInverse(); + if (isInverse) { + const temp = fg; + fg = bg; + bg = temp; + const temp2 = fgColorMode; + fgColorMode = bgColorMode; + bgColorMode = temp2; } - // bg - if (this._workCell.isBgRGB()) { - let style = charElement.getAttribute('style') || ''; - style += `${swapColor ? '' : 'background-'}color:rgb(${(AttributeData.toColorRGB(this._workCell.getBgColor())).join(',')});`; - charElement.setAttribute('style', style); - } else if (this._workCell.isBgPalette()) { - charElement.classList.add(`xterm-${swapColor ? 'f' : 'b'}g-${this._workCell.getBgColor()}`); - } else if (swapColor) { - charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); + // Foreground + switch (fgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + if (this._workCell.isBold() && fg < 8 && this._optionsService.options.drawBoldTextInBrightColors) { + fg += 8; + } + if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.ansi[fg])) { + charElement.classList.add(`xterm-fg-${fg}`); + } + break; + case Attributes.CM_RGB: + charElement.setAttribute('style', `${charElement.getAttribute('style') || ''}color:rgb(${(AttributeData.toColorRGB(fg)).join(',')});`); + break; + case Attributes.CM_DEFAULT: + default: + if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.foreground)) { + if (isInverse) { + charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); + } + } + } + + // Background + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + charElement.classList.add(`xterm-bg-${bg}`); + break; + case Attributes.CM_RGB: + charElement.setAttribute('style', `${charElement.getAttribute('style') || ''}background-color:rgb(${(AttributeData.toColorRGB(bg)).join(',')});`); + break; + case Attributes.CM_DEFAULT: + default: + if (isInverse) { + charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); + } } fragment.appendChild(charElement); } return fragment; } + + private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor): boolean { + if (this._optionsService.options.minimumContrastRatio === 1) { + return false; + } + + const adustedColor = ensureContrastRatio(bg, fg, this._optionsService.options.minimumContrastRatio); + if (adustedColor) { + element.setAttribute('style', `${element.getAttribute('style') || ''}color:${adustedColor.css}`); + return true; + } + + return false; + } } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index d9ea60d5a..9174398ec 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -37,6 +37,7 @@ export const DEFAULT_OPTIONS: ITerminalOptions = Object.freeze({ screenReaderMode: false, macOptionIsMeta: false, macOptionClickForcesSelection: false, + minimumContrastRatio: 6, disableStdin: false, allowTransparency: false, tabStopWidth: 8, @@ -116,6 +117,8 @@ export class OptionsService implements IOptionsService { throw new Error(`${key} cannot be less than 1, value: ${value}`); } break; + case 'minimumContrastRatio': + value = Math.max(1, Math.min(21, Math.round(value * 10) / 10)); case 'scrollback': value = Math.min(value, 4294967295); if (value < 0) { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index e4ff90d0f..1a39cced7 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -229,6 +229,7 @@ export interface ITerminalOptions { logLevel: LogLevel; macOptionIsMeta: boolean; macOptionClickForcesSelection: boolean; + minimumContrastRatio: number; rendererType: RendererType; rightClickSelectsWord: boolean; rows: number; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 9728a72cb..b49ed04bf 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -148,6 +148,14 @@ declare module 'xterm' { */ macOptionClickForcesSelection?: boolean; + /** + * The minimum contrast ratio for text in the terminal, setting this will + * change the foreground color dynamically depending on whether the contrast + * ratio is met. This can be to set 0.1 increments between 1 (default, do + * nothing) and 21 (foreground will be black or white). + */ + minimumContrastRatio?: number; + /** * The type of renderer to use, this allows using the fallback DOM renderer * when canvas is too slow for the environment. The following features do