diff --git a/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts b/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts index df8f1dc2b3b4bfc8d95c6f5550da5933d3d7df46..9b4114abc7a5fcddf6716ca586a594d3da194099 100644 --- a/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts +++ b/src/vs/workbench/contrib/debug/browser/debugANSIHandling.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector'; +import { RGBA, Color } from 'vs/base/common/color'; /** * @param text The content to stylize. @@ -15,6 +16,8 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector): HTML const textLength: number = text.length; let styleNames: string[] = []; + let customFgColor: RGBA | undefined; + let customBgColor: RGBA | undefined; let currentPos: number = 0; let buffer: string = ''; @@ -48,45 +51,33 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector): HTML if (sequenceFound) { // Flush buffer with previous styles. - appendStylizedStringToContainer(root, buffer, styleNames, linkDetector); + appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, customFgColor, customBgColor); buffer = ''; /* - * Certain ranges that are matched here do not contain real graphics rendition sequences. For - * the sake of having a simpler expression, they have been included anyway. - */ - if (ansiSequence.match(/^(?:[349][0-7]|10[0-7]|[013]|4|[34]9)(?:;(?:[349][0-7]|10[0-7]|[013]|4|[34]9))*;?m$/)) { - - const styleCodes: number[] = ansiSequence.slice(0, -1) // Remove final 'm' character. - .split(';') // Separate style codes. - .filter(elem => elem !== '') // Filter empty elems as '34;m' -> ['34', '']. - .map(elem => parseInt(elem, 10)); // Convert to numbers. - - for (let code of styleCodes) { - if (code === 0) { - styleNames = []; - } else if (code === 1) { - styleNames.push('code-bold'); - } else if (code === 3) { - styleNames.push('code-italic'); - } else if (code === 4) { - styleNames.push('code-underline'); - } else if (code === 39 || (code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { - // Remove all previous foreground colour codes - styleNames = styleNames.filter(style => !style.match(/^code-foreground-\d+$/)); - - if (code !== 39) { - styleNames.push('code-foreground-' + code); - } - } else if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { - // Remove all previous background colour codes - styleNames = styleNames.filter(style => !style.match(/^code-background-\d+$/)); - - if (code !== 49) { - styleNames.push('code-background-' + code); - } + * Certain ranges that are matched here do not contain real graphics rendition sequences. For + * the sake of having a simpler expression, they have been included anyway. + */ + if (ansiSequence.match(/^(?:[34][0-8]|9[0-7]|10[0-7]|[013]|4|[34]9)(?:;[349][0-7]|10[0-7]|[013]|[245]|[34]9)?(?:;[012]?[0-9]?[0-9])*;?m$/)) { + + const styleCodes: number[] = ansiSequence.slice(0, -1) // Remove final 'm' character. + .split(';') // Separate style codes. + .filter(elem => elem !== '') // Filter empty elems as '34;m' -> ['34', '']. + .map(elem => parseInt(elem, 10)); // Convert to numbers. + + if (styleCodes[0] === 38 || styleCodes[0] === 48) { + // Advanced color code - can't be combined with formatting codes like simple colors can + // Ignores invalid colors and additional info beyond what is necessary + const colorType = (styleCodes[0] === 38) ? 'foreground' : 'background'; + + if (styleCodes[1] === 5) { + set8BitColor(styleCodes, colorType); + } else if (styleCodes[1] === 2) { + set24BitColor(styleCodes, colorType); } + } else { + setBasicFormatters(styleCodes); } } else { @@ -96,23 +87,121 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector): HTML } else { currentPos = startPos; } - } if (sequenceFound === false) { buffer += text.charAt(currentPos); currentPos++; } - } // Flush remaining text buffer if not empty. if (buffer) { - appendStylizedStringToContainer(root, buffer, styleNames, linkDetector); + appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, customFgColor, customBgColor); } return root; + /** + * Change the foreground or background color by clearing the current color + * and adding the new one. + * @param newClass If string or number, new class will be + * `code-(foreground or background)-newClass`. If `undefined`, no new class + * will be added. + * @param colorType If `'foreground'`, will change the foreground color, if + * `'background'`, will change the background color. + * @param customColor If provided, this custom color will be used instead of + * a class-defined color. + */ + function changeColor(newClass: string | number | undefined, colorType: 'foreground' | 'background', customColor?: RGBA): void { + styleNames = styleNames.filter(style => !style.match(new RegExp(`^code-${colorType}-(\\d+|custom)$`))); + if (newClass) { + styleNames.push(`code-${colorType}-${newClass}`); + } + if (colorType === 'foreground') { + customFgColor = customColor; + } else { + customBgColor = customColor; + } + } + + /** + * Calculate and set basic ANSI formatting. Supports bold, italic, underline, + * normal foreground and background colors, and bright foreground and + * background colors. Not to be used for codes containing advanced colors. + * Will ignore invalid codes. + * @param styleCodes Array of ANSI basic styling numbers, which will be + * applied in order. New colors and backgrounds clear old ones; new formatting + * does not. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code } + */ + function setBasicFormatters(styleCodes: number[]): void { + for (let code of styleCodes) { + if (code === 0) { + styleNames = []; + } else if (code === 1) { + styleNames.push('code-bold'); + } else if (code === 3) { + styleNames.push('code-italic'); + } else if (code === 4) { + styleNames.push('code-underline'); + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + changeColor(code, 'foreground'); + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + changeColor(code, 'background'); + } else if (code === 39) { + changeColor(undefined, 'foreground'); + } else if (code === 49) { + changeColor(undefined, 'background'); + } + } + } + + /** + * Calculate and set styling for complicated 24-bit ANSI color codes. + * @param styleCodes Full list of integer codes that make up the full ANSI + * sequence, including the two defining codes and the three RGB codes. + * @param colorType If `'foreground'`, will set foreground color, if + * `'background'`, will set background color. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#24-bit } + */ + function set24BitColor(styleCodes: number[], colorType: 'foreground' | 'background'): void { + if (styleCodes.length >= 5 && + styleCodes[2] >= 0 && styleCodes[2] <= 255 && + styleCodes[3] >= 0 && styleCodes[3] <= 255 && + styleCodes[4] >= 0 && styleCodes[4] <= 255) { + const customColor = new RGBA(styleCodes[2], styleCodes[3], styleCodes[4]); + changeColor('custom', colorType, customColor); + } + } + + /** + * Calculate and set styling for advanced 8-bit ANSI color codes. + * @param styleCodes Full list of integer codes that make up the ANSI + * sequence, including the two defining codes and the one color code. + * @param colorType If `'foreground'`, will set foreground color, if + * `'background'`, will set background color. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit } + */ + function set8BitColor(styleCodes: number[], colorType: 'foreground' | 'background'): void { + let colorNumber = styleCodes[2]; + const color = calcANSI8bitColor(colorNumber); + + if (color) { + changeColor('custom', colorType, color); + } else if (colorNumber >= 0 && colorNumber <= 15) { + // Need to map to one of the four basic color ranges (30-37, 90-97, 40-47, 100-107) + colorNumber += 30; + if (colorNumber >= 38) { + // Bright colors + colorNumber += 52; + } + if (colorType === 'background') { + colorNumber += 10; + } + changeColor(colorNumber, colorType); + } + } } /** @@ -120,8 +209,17 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector): HTML * @param stringContent The text content to be appended. * @param cssClasses The list of CSS styles to apply to the text content. * @param linkDetector The {@link LinkDetector} responsible for generating links from {@param stringContent}. + * @param customTextColor If provided, will apply custom color with inline style. + * @param customBackgroundColor If provided, will apply custom color with inline style. */ -export function appendStylizedStringToContainer(root: HTMLElement, stringContent: string, cssClasses: string[], linkDetector: LinkDetector): void { +export function appendStylizedStringToContainer( + root: HTMLElement, + stringContent: string, + cssClasses: string[], + linkDetector: LinkDetector, + customTextColor?: RGBA, + customBackgroundColor?: RGBA +): void { if (!root || !stringContent) { return; } @@ -129,5 +227,53 @@ export function appendStylizedStringToContainer(root: HTMLElement, stringContent const container = linkDetector.handleLinks(stringContent); container.className = cssClasses.join(' '); + if (customTextColor) { + container.style.color = + Color.Format.CSS.formatRGB(new Color(customTextColor)); + } + if (customBackgroundColor) { + container.style.backgroundColor = + Color.Format.CSS.formatRGB(new Color(customBackgroundColor)); + } + root.appendChild(container); } + +/** + * Calculate the color from the color set defined in the ANSI 8-bit standard. + * Standard and high intensity colors are not defined in the standard as specific + * colors, so these and invalid colors return `undefined`. + * @see {@link https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit } for info. + * @param colorNumber The number (ranging from 16 to 255) referring to the color + * desired. + */ +export function calcANSI8bitColor(colorNumber: number): RGBA | undefined { + if (colorNumber % 1 !== 0) { + // Should be integer + return; + } if (colorNumber >= 16 && colorNumber <= 231) { + // Converts to one of 216 RGB colors + colorNumber -= 16; + + let blue: number = colorNumber % 6; + colorNumber = (colorNumber - blue) / 6; + let green: number = colorNumber % 6; + colorNumber = (colorNumber - green) / 6; + let red: number = colorNumber; + + // red, green, blue now range on [0, 5], need to map to [0,255] + const convFactor: number = 255 / 5; + blue = Math.round(blue * convFactor); + green = Math.round(green * convFactor); + red = Math.round(red * convFactor); + + return new RGBA(red, green, blue); + } else if (colorNumber >= 232 && colorNumber <= 255) { + // Converts to a grayscale value + colorNumber -= 232; + const colorLevel: number = Math.round(colorNumber / 23 * 255); + return new RGBA(colorLevel, colorLevel, colorLevel); + } else { + return; + } +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts index 8263031e07868508a20bdd5dbf03d6ebc00da902..65522e21e2ef70d116cbb5a04b6539947d1648fd 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugANSIHandling.test.ts @@ -6,10 +6,11 @@ import * as assert from 'assert'; import * as dom from 'vs/base/browser/dom'; import { generateUuid } from 'vs/base/common/uuid'; -import { appendStylizedStringToContainer, handleANSIOutput } from 'vs/workbench/contrib/debug/browser/debugANSIHandling'; +import { appendStylizedStringToContainer, handleANSIOutput, calcANSI8bitColor } from 'vs/workbench/contrib/debug/browser/debugANSIHandling'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { workbenchInstantiationService } from 'vs/workbench/test/workbenchTestServices'; import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector'; +import { Color, RGBA } from 'vs/base/common/color'; suite('Debug - ANSI Handling', () => { @@ -182,6 +183,119 @@ suite('Debug - ANSI Handling', () => { }); + test('Expected single 8-bit color sequence operation', () => { + // Basic color codes specified with 8-bit color code format + for (let i = 0; i <= 7; i++) { + // Foreground codes should add standard classes + const fgStyle: string = 'code-foreground-' + (i + 30); + assertSingleSequenceElement('\x1b[38;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, fgStyle)); + }); + + // Background codes should add standard classes + const bgStyle: string = 'code-background-' + (i + 40); + assertSingleSequenceElement('\x1b[48;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, bgStyle)); + }); + } + + // Bright color codes specified with 8-bit color code format + for (let i = 8; i <= 15; i++) { + // Foreground codes should add standard classes + const fgStyle: string = 'code-foreground-' + (i - 8 + 90); + assertSingleSequenceElement('\x1b[38;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, fgStyle)); + }); + + // Background codes should add standard classes + const bgStyle: string = 'code-background-' + (i - 8 + 100); + assertSingleSequenceElement('\x1b[48;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, bgStyle)); + }); + } + + // 8-bit advanced colors + for (let i = 16; i <= 255; i++) { + const color = Color.Format.CSS.formatRGB( + new Color((calcANSI8bitColor(i) as RGBA)) + ); + + // Foreground codes should add custom class and inline style + assertSingleSequenceElement('\x1b[38;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, 'code-foreground-custom')); + assert(child.style.color === color); + }); + + // Background codes should add custom class and inline style + assertSingleSequenceElement('\x1b[48;5;' + i + 'm', (child) => { + assert(dom.hasClass(child, 'code-background-custom')); + assert(child.style.backgroundColor === color); + }); + } + + // Bad (nonexistent) color should not render + assertSingleSequenceElement('\x1b[48;5;300m', (child) => { + assert.equal(0, child.classList.length); + }); + + // Should ignore any codes after the ones needed to determine color + assertSingleSequenceElement('\x1b[48;5;100;42;77;99;4;24m', (child) => { + const color = Color.Format.CSS.formatRGB( + new Color((calcANSI8bitColor(100) as RGBA)) + ); + assert(dom.hasClass(child, 'code-background-custom')); + assert.equal(1, child.classList.length); + assert(child.style.backgroundColor === color); + }); + }); + + test('Expected single 24-bit color sequence operation', () => { + // 24-bit advanced colors + for (let r = 0; r <= 255; r += 64) { + for (let g = 0; g <= 255; g += 64) { + for (let b = 0; b <= 255; b += 64) { + let cssColor: string = `rgb(${r},${g},${b})`; + // Foreground codes should add custom class and inline style + assertSingleSequenceElement(`\x1b[38;2;${r};${g};${b}m`, (child) => { + assert(dom.hasClass(child, 'code-foreground-custom'), 'DOM should have "code-foreground-custom" class for advanced ANSI colors.'); + const styleBefore = child.style.color; + child.style.color = cssColor; + assert(styleBefore === child.style.color, `Incorrect inline color style found for ${cssColor} (found color: ${styleBefore}).`); + }); + + // Background codes should add custom class and inline style + assertSingleSequenceElement(`\x1b[48;2;${r};${g};${b}m`, (child) => { + assert(dom.hasClass(child, 'code-background-custom'), 'DOM should have "code-foreground-custom" class for advanced ANSI colors.'); + const styleBefore = child.style.backgroundColor; + child.style.backgroundColor = cssColor; + assert(styleBefore === child.style.backgroundColor, `Incorrect inline color style found for ${cssColor} (found color: ${styleBefore}).`); + }); + } + } + } + + // Invalid color should not render + assertSingleSequenceElement('\x1b[38;2;4;4m', (child) => { + assert.equal(0, child.classList.length, `Invalid color code "38;2;4;4" should not add a class (classes found: ${child.classList}).`); + assert(child.style.color === '', `Invalid color code "38;2;4;4" should not add a custom color CSS (found color: ${child.style.color}).`); + }); + + // Bad (nonexistent) color should not render + assertSingleSequenceElement('\x1b[48;2;150;300;5m', (child) => { + assert.equal(0, child.classList.length, `Nonexistent color code "48;2;150;300;5" should not add a class (classes found: ${child.classList}).`); + }); + + // Should ignore any codes after the ones needed to determine color + assertSingleSequenceElement('\x1b[48;2;100;42;77;99;200;75m', (child) => { + assert(dom.hasClass(child, 'code-background-custom'), `Color code with extra (valid) items "48;2;100;42;77;99;200;75" should still treat initial part as valid code and add class "code-background-custom".`); + assert.equal(1, child.classList.length, `Color code with extra items "48;2;100;42;77;99;200;75" should add one and only one class. (classes found: ${child.classList}).`); + let styleBefore = child.style.backgroundColor; + child.style.backgroundColor = 'rgb(100,42,77)'; + assert(child.style.backgroundColor === styleBefore, `Color code "48;2;100;42;77;99;200;75" should style background-color as rgb(100,42,77).`); + }); + }); + + /** * Assert that a given ANSI sequence produces the expected number of {@link HTMLSpanElement} children. For * each child, run the provided assertion. @@ -189,10 +303,13 @@ suite('Debug - ANSI Handling', () => { * @param sequence The ANSI sequence to verify. * @param assertions A set of assertions to run on the resulting children. */ - function assertMultipleSequenceElements(sequence: string, assertions: Array<(child: HTMLSpanElement) => void>): void { + function assertMultipleSequenceElements(sequence: string, assertions: Array<(child: HTMLSpanElement) => void>, elementsExpected?: number): void { + if (elementsExpected === undefined) { + elementsExpected = assertions.length; + } const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector); - assert.equal(assertions.length, root.children.length); - for (let i = 0; i < assertions.length; i++) { + assert.equal(elementsExpected, root.children.length); + for (let i = 0; i < elementsExpected; i++) { const child: Node = root.children[i]; if (child instanceof HTMLSpanElement) { assertions[i](child); @@ -239,7 +356,31 @@ suite('Debug - ANSI Handling', () => { (nothing) => { assert.equal(0, nothing.classList.length); }, - ]); + ], 5); + + // Different types of color codes still cancel each other + assertMultipleSequenceElements('\x1b[34msimple\x1b[38;2;100;100;100m24bit\x1b[38;5;3m8bitsimple\x1b[38;5;101m8bitadvanced', [ + (simple) => { + assert.equal(1, simple.classList.length); + assert(dom.hasClass(simple, 'code-foreground-34')); + }, + (adv24Bit) => { + assert.equal(1, adv24Bit.classList.length); + assert(dom.hasClass(adv24Bit, 'code-foreground-custom')); + let styleBefore = adv24Bit.style.color; + adv24Bit.style.color = 'rgb(100,100,100)'; + assert(adv24Bit.style.color === styleBefore); + }, + (adv8BitSimple) => { + assert.equal(1, adv8BitSimple.classList.length); + assert(dom.hasClass(adv8BitSimple, 'code-foreground-33')); + assert(adv8BitSimple.style.color === ''); + }, + (adv8BitAdvanced) => { + assert.equal(1, adv8BitAdvanced.classList.length); + assert(dom.hasClass(adv8BitAdvanced, 'code-foreground-custom')); + } + ], 4); }); @@ -292,8 +433,6 @@ suite('Debug - ANSI Handling', () => { '\x1b[;m', '\x1b[1;;m', '\x1b[m', - // Unsupported colour codes - '\x1b[30;50m', '\x1b[99m' ]; @@ -310,4 +449,40 @@ suite('Debug - ANSI Handling', () => { }); + test('calcANSI8bitColor', () => { + // Invalid values + // Negative (below range), simple range, decimals + for (let i = -10; i <= 15; i += 0.5) { + assert(calcANSI8bitColor(i) === undefined, 'Values less than 16 passed to calcANSI8bitColor should return undefined.'); + } + // In-range range decimals + for (let i = 16.5; i < 254; i += 1) { + assert(calcANSI8bitColor(i) === undefined, 'Floats passed to calcANSI8bitColor should return undefined.'); + } + // Above range + for (let i = 256; i < 300; i += 0.5) { + assert(calcANSI8bitColor(i) === undefined, 'Values grather than 255 passed to calcANSI8bitColor should return undefined.'); + } + + // All valid colors + for (let red = 0; red <= 5; red++) { + for (let green = 0; green <= 5; green++) { + for (let blue = 0; blue <= 5; blue++) { + let colorOut: any = calcANSI8bitColor(16 + red * 36 + green * 6 + blue); + assert(colorOut.r === Math.round(red * (255 / 5)), 'Incorrect red value encountered for color'); + assert(colorOut.g === Math.round(green * (255 / 5)), 'Incorrect green value encountered for color'); + assert(colorOut.b === Math.round(blue * (255 / 5)), 'Incorrect balue value encountered for color'); + } + } + } + + // All grays + for (let i = 232; i <= 255; i++) { + let grayOut: any = calcANSI8bitColor(i); + assert(grayOut.r === grayOut.g); + assert(grayOut.r === grayOut.b); + assert(grayOut.r === Math.round((i - 232) / 23 * 255)); + } + }); + });