@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface ITerminalOptions {
|
||||
logLevel: LogLevel;
|
||||
macOptionIsMeta: boolean;
|
||||
macOptionClickForcesSelection: boolean;
|
||||
minimumContrastRatio: number;
|
||||
rendererType: RendererType;
|
||||
rightClickSelectsWord: boolean;
|
||||
rows: number;
|
||||
|
||||
Vendored
+8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user