Skip to content

Commit

Permalink
debug: ensure ansi colors for debug output have appropriate contrast (m…
Browse files Browse the repository at this point in the history
…icrosoft#228758)

Borrow's some additional color logic from xterm and uses that to ensure debug's output is contrasting enough wherever it's displayed.
  • Loading branch information
connor4312 authored Sep 16, 2024
1 parent 6f286b8 commit b2e8842
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 2 deletions.
85 changes: 85 additions & 0 deletions src/vs/base/common/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,50 @@ export class Color {
return roundFloat(luminance, 4);
}

/**
* Reduces the "foreground" color on this "background" color unti it is
* below the relative luminace ratio.
* @returns the new foreground color
* @see https://github.com/xtermjs/xterm.js/blob/44f9fa39ae03e2ca6d28354d88a399608686770e/src/common/Color.ts#L315
*/
reduceRelativeLuminace(foreground: Color, ratio: number): Color {
// This is a naive but fast approach to reducing luminance as converting to
// HSL and back is expensive
let { r: fgR, g: fgG, b: fgB } = foreground.rgba;

let cr = this.getContrastRatio(foreground);
while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {
// Reduce by 10% 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 = this.getContrastRatio(new Color(new RGBA(fgR, fgG, fgB)));
}

return new Color(new RGBA(fgR, fgG, fgB));
}

/**
* Increases the "foreground" color on this "background" color unti it is
* below the relative luminace ratio.
* @returns the new foreground color
* @see https://github.com/xtermjs/xterm.js/blob/44f9fa39ae03e2ca6d28354d88a399608686770e/src/common/Color.ts#L335
*/
increaseRelativeLuminace(foreground: Color, ratio: number): Color {
// This is a naive but fast approach to reducing luminance as converting to
// HSL and back is expensive
let { r: fgR, g: fgG, b: fgB } = foreground.rgba;
let cr = this.getContrastRatio(foreground);
while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {
fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));
fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));
fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));
cr = this.getContrastRatio(new Color(new RGBA(fgR, fgG, fgB)));
}

return new Color(new RGBA(fgR, fgG, fgB));
}

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);
Expand Down Expand Up @@ -365,6 +409,47 @@ export class Color {
return lum1 < lum2;
}

/**
* Based on xterm.js: https://github.com/xtermjs/xterm.js/blob/44f9fa39ae03e2ca6d28354d88a399608686770e/src/common/Color.ts#L288
*
* Given a foreground color and a background color, either increase or reduce the luminance of the
* foreground color until the specified contrast ratio is met. If pure white or black is hit
* without the contrast ratio being met, go the other direction using the background color as the
* foreground color and take either the first or second result depending on which has the higher
* contrast ratio.
*
* @param foreground The foreground color.
* @param ratio The contrast ratio to achieve.
* @returns The adjusted foreground color.
*/
ensureConstrast(foreground: Color, ratio: number): Color {
const bgL = this.getRelativeLuminance();
const fgL = foreground.getRelativeLuminance();
const cr = this.getContrastRatio(foreground);
if (cr < ratio) {
if (fgL < bgL) {
const resultA = this.reduceRelativeLuminace(foreground, ratio);
const resultARatio = this.getContrastRatio(resultA);
if (resultARatio < ratio) {
const resultB = this.increaseRelativeLuminace(foreground, ratio);
const resultBRatio = this.getContrastRatio(resultB);
return resultARatio > resultBRatio ? resultA : resultB;
}
return resultA;
}
const resultA = this.increaseRelativeLuminace(foreground, ratio);
const resultARatio = this.getContrastRatio(resultA);
if (resultARatio < ratio) {
const resultB = this.reduceRelativeLuminace(foreground, ratio);
const resultBRatio = this.getContrastRatio(resultB);
return resultARatio > resultBRatio ? resultA : resultB;
}
return resultA;
}

return foreground;
}

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));
}
Expand Down
67 changes: 67 additions & 0 deletions src/vs/base/test/common/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,71 @@ suite('Color', () => {
});
});
});

const rgbaFromInt = (int: number) => new Color(new RGBA(
(int >> 24) & 0xff,
(int >> 16) & 0xff,
(int >> 8) & 0xff,
(int) & 0xff
));

const assertContrastRatio = (background: number, foreground: number, ratio: number, expected = foreground) => {
const bgColor = rgbaFromInt(background);
const fgColor = rgbaFromInt(foreground);
assert.deepStrictEqual(bgColor.ensureConstrast(fgColor, ratio).rgba, rgbaFromInt(expected).rgba);
};

// https://github.com/xtermjs/xterm.js/blob/44f9fa39ae03e2ca6d28354d88a399608686770e/src/common/Color.test.ts#L355
suite('ensureContrastRatio', () => {
test('should return undefined if the color already meets the contrast ratio (black bg)', () => {
assertContrastRatio(0x000000ff, 0x606060ff, 1, undefined);
assertContrastRatio(0x000000ff, 0x606060ff, 2, undefined);
assertContrastRatio(0x000000ff, 0x606060ff, 3, undefined);
});
test('should return a color that meets the contrast ratio (black bg)', () => {
assertContrastRatio(0x000000ff, 0x606060ff, 4, 0x707070ff);
assertContrastRatio(0x000000ff, 0x606060ff, 5, 0x7f7f7fff);
assertContrastRatio(0x000000ff, 0x606060ff, 6, 0x8c8c8cff);
assertContrastRatio(0x000000ff, 0x606060ff, 7, 0x989898ff);
assertContrastRatio(0x000000ff, 0x606060ff, 8, 0xa3a3a3ff);
assertContrastRatio(0x000000ff, 0x606060ff, 9, 0xadadadff);
assertContrastRatio(0x000000ff, 0x606060ff, 10, 0xb6b6b6ff);
assertContrastRatio(0x000000ff, 0x606060ff, 11, 0xbebebeff);
assertContrastRatio(0x000000ff, 0x606060ff, 12, 0xc5c5c5ff);
assertContrastRatio(0x000000ff, 0x606060ff, 13, 0xd1d1d1ff);
assertContrastRatio(0x000000ff, 0x606060ff, 14, 0xd6d6d6ff);
assertContrastRatio(0x000000ff, 0x606060ff, 15, 0xdbdbdbff);
assertContrastRatio(0x000000ff, 0x606060ff, 16, 0xe3e3e3ff);
assertContrastRatio(0x000000ff, 0x606060ff, 17, 0xe9e9e9ff);
assertContrastRatio(0x000000ff, 0x606060ff, 18, 0xeeeeeeff);
assertContrastRatio(0x000000ff, 0x606060ff, 19, 0xf4f4f4ff);
assertContrastRatio(0x000000ff, 0x606060ff, 20, 0xfafafaff);
assertContrastRatio(0x000000ff, 0x606060ff, 21, 0xffffffff);
});
test('should return undefined if the color already meets the contrast ratio (white bg)', () => {
assertContrastRatio(0xffffffff, 0x606060ff, 1, undefined);
assertContrastRatio(0xffffffff, 0x606060ff, 2, undefined);
assertContrastRatio(0xffffffff, 0x606060ff, 3, undefined);
assertContrastRatio(0xffffffff, 0x606060ff, 4, undefined);
assertContrastRatio(0xffffffff, 0x606060ff, 5, undefined);
assertContrastRatio(0xffffffff, 0x606060ff, 6, undefined);
});
test('should return a color that meets the contrast ratio (white bg)', () => {
assertContrastRatio(0xffffffff, 0x606060ff, 7, 0x565656ff);
assertContrastRatio(0xffffffff, 0x606060ff, 8, 0x4d4d4dff);
assertContrastRatio(0xffffffff, 0x606060ff, 9, 0x454545ff);
assertContrastRatio(0xffffffff, 0x606060ff, 10, 0x3e3e3eff);
assertContrastRatio(0xffffffff, 0x606060ff, 11, 0x373737ff);
assertContrastRatio(0xffffffff, 0x606060ff, 12, 0x313131ff);
assertContrastRatio(0xffffffff, 0x606060ff, 13, 0x313131ff);
assertContrastRatio(0xffffffff, 0x606060ff, 14, 0x272727ff);
assertContrastRatio(0xffffffff, 0x606060ff, 15, 0x232323ff);
assertContrastRatio(0xffffffff, 0x606060ff, 16, 0x1f1f1fff);
assertContrastRatio(0xffffffff, 0x606060ff, 17, 0x1b1b1bff);
assertContrastRatio(0xffffffff, 0x606060ff, 18, 0x151515ff);
assertContrastRatio(0xffffffff, 0x606060ff, 19, 0x101010ff);
assertContrastRatio(0xffffffff, 0x606060ff, 20, 0x080808ff);
assertContrastRatio(0xffffffff, 0x606060ff, 21, 0x000000ff);
});
});
});
31 changes: 29 additions & 2 deletions src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
*--------------------------------------------------------------------------------------------*/

import { Color, RGBA } from '../../../../base/common/color.js';
import { isDefined } from '../../../../base/common/types.js';
import { editorHoverBackground } from '../../../../platform/theme/common/colorRegistry.js';
import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js';
import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';
import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from '../../../common/theme.js';
import { ansiColorIdentifiers } from '../../terminal/common/terminalColorRegistry.js';
import { ILinkDetector } from './linkDetector.js';

Expand Down Expand Up @@ -334,7 +338,7 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work
if (colorType === 'underline') {
// for underline colors we just decode the 0-15 color number to theme color, set and return
const colorName = ansiColorIdentifiers[colorNumber];
changeColor(colorType, `--vscode-treminal-${colorName}`);
changeColor(colorType, `--vscode-debug-ansi-${colorName}`);
return;
}
// Need to map to one of the four basic color ranges (30-37, 90-97, 40-47, 100-107)
Expand Down Expand Up @@ -378,7 +382,7 @@ export function handleANSIOutput(text: string, linkDetector: ILinkDetector, work

if (colorIndex !== undefined && colorType) {
const colorName = ansiColorIdentifiers[colorIndex];
changeColor(colorType, `--vscode-${colorName.replaceAll('.', '-')}`);
changeColor(colorType, `--vscode-debug-ansi-${colorName.replaceAll('.', '-')}`);
}
}
}
Expand Down Expand Up @@ -463,3 +467,26 @@ export function calcANSI8bitColor(colorNumber: number): RGBA | undefined {
return;
}
}

registerThemingParticipant((theme, collector) => {
const areas = [
{ selector: '.monaco-workbench .sidebar, .monaco-workbench .auxiliarybar', bg: theme.getColor(SIDE_BAR_BACKGROUND) },
{ selector: '.monaco-workbench .panel', bg: theme.getColor(PANEL_BACKGROUND) },
{ selector: '.debug-hover-widget', bg: theme.getColor(editorHoverBackground) },
];

for (const { selector, bg } of areas) {
const content = ansiColorIdentifiers
.map(color => {
const actual = theme.getColor(color);
if (!actual) { return undefined; }
// this uses the default contrast ratio of 4 (from the terminal),
// we may want to make this configurable in the future, but this is
// good to keep things sane to start with.
return `--vscode-debug-ansi-${color.replaceAll('.', '-')}:${bg ? bg.ensureConstrast(actual, 4) : actual}`;
})
.filter(isDefined);

collector.addRule(`${selector} { ${content.join(';')} }`);
}
});

0 comments on commit b2e8842

Please sign in to comment.