From 54375713bc2b049648f03540e611f9c3abd58561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 8 Nov 2021 17:17:39 +0100 Subject: [PATCH] simplify color events --- src/browser/Color.ts | 6 +- src/browser/Terminal.ts | 71 ++++--------- src/common/InputHandler.test.ts | 144 ++++++++++++--------------- src/common/InputHandler.ts | 96 +++++------------- src/common/Types.d.ts | 21 ++-- src/common/input/XParseColor.test.ts | 93 +++++++++++++++++ src/common/input/XParseColor.ts | 77 ++++++++++++++ 7 files changed, 291 insertions(+), 217 deletions(-) create mode 100644 src/common/input/XParseColor.test.ts create mode 100644 src/common/input/XParseColor.ts diff --git a/src/browser/Color.ts b/src/browser/Color.ts index 2e71c9e6..6a9dff94 100644 --- a/src/browser/Color.ts +++ b/src/browser/Color.ts @@ -4,6 +4,7 @@ */ import { IColor } from 'browser/Types'; +import { IColorRGB } from 'common/Types'; // FIXME: Move Color.ts lib to common? @@ -86,9 +87,8 @@ export namespace color { }; } - export function toXColorName(color: IColor): string { - const [r, g, b] = rgba.toChannels(color.rgba); - return `rgb:${toPaddedHex(r)}${toPaddedHex(r)}/${toPaddedHex(g)}${toPaddedHex(g)}/${toPaddedHex(b)}${toPaddedHex(b)}`; + export function toColorRGB(color: IColor): IColorRGB { + return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF]; } } diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index e8aee572..0602f604 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -39,7 +39,7 @@ import { MouseZoneManager } from 'browser/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm'; import { DomRenderer } from 'browser/renderer/dom/DomRenderer'; -import { IKeyboardEvent, KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent } from 'common/Types'; +import { IKeyboardEvent, KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, IColorRGB } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; @@ -55,6 +55,7 @@ import { CoreTerminal } from 'common/CoreTerminal'; import { ITerminalOptions as IInitializedTerminalOptions } from 'common/services/Services'; import { color, rgba } from 'browser/Color'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; +import { toRgbString } from 'common/input/XParseColor'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; @@ -178,58 +179,26 @@ export class Terminal extends CoreTerminal implements ITerminal { } private _handleColorEvent(event: IColorEvent): void { - if (!this._colorManager) { return; } - - let hasSet = false; - const query: string[] = []; - for (const req of event.requests) { - if (req.color === '?') { - // query color - let ident = ''; - let c: IColor; - switch (req.index) { - case 256: - ident = '10'; - c = this._colorManager.colors.foreground; - break; - case 257: - ident = '11'; - c = this._colorManager.colors.background; - break; - default: - if (0 <= req.index && req.index < 256) { - ident = '4;' + req.index; - c = this._colorManager.colors.ansi[req.index]; - } - } - if (ident) { - query.push(`${C0.ESC}]${ident};${color.toXColorName(c!)}${C0.BEL}`); - } - } else { - // set color - hasSet = true; - switch (req.index) { - case 256: - this._colorManager.colors.foreground = rgba.toColor(...req.color); - break; - case 257: - this._colorManager.colors.background = rgba.toColor(...req.color); - break; - default: - if (0 <= req.index && req.index < 256) { - this._colorManager.colors.ansi[req.index] = rgba.toColor(...req.color); - } - } + if (!this._colorManager) return; + for (const req of event) { + switch (req.index) { + case ColorIndex.FOREGROUND: // OSC 10 + if (req.color) this._colorManager.colors.foreground = rgba.toColor(...req.color); + else this.coreService.triggerDataEvent(`${C0.ESC}]10;${toRgbString(color.toColorRGB(this._colorManager.colors.foreground))}${C0.BEL}`); + break; + case ColorIndex.BACKGROUND: // OSC 11 + if (req.color) this._colorManager.colors.background = rgba.toColor(...req.color); + else this.coreService.triggerDataEvent(`${C0.ESC}]11;${toRgbString(color.toColorRGB(this._colorManager.colors.background))}${C0.BEL}`); + break; + default: // OSC 4 + if (0 <= req.index && req.index < 256) { + if (req.color) this._colorManager.colors.ansi[req.index] = rgba.toColor(...req.color); + else this.coreService.triggerDataEvent(`${C0.ESC}]4;${req.index};${toRgbString(color.toColorRGB(this._colorManager.colors.ansi[req.index]))}${C0.BEL}`); + } } } - - if (query.length) { - this.coreService.triggerDataEvent(query.join('')); - } - if (hasSet) { - this._renderService?.setColors(this._colorManager.colors); - this.viewport?.onThemeChange(this._colorManager.colors); - } + this._renderService?.setColors(this._colorManager.colors); + this.viewport?.onThemeChange(this._colorManager.colors); } public dispose(): void { diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index e8c4c8bf..cc963809 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { InputHandler } from 'common/InputHandler'; -import { IBufferLine, IAttributeData, IColorEvent } from 'common/Types'; +import { IBufferLine, IAttributeData, IColorEvent, ColorIndex } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; @@ -41,7 +41,7 @@ class TestInputHandler extends InputHandler { public get curAttrData(): IAttributeData { return (this as any)._curAttrData; } public get windowTitleStack(): string[] { return this._windowTitleStack; } public get iconNameStack(): string[] { return this._iconNameStack; } - public parseAnsiColorChange(data: string): IColorEvent | null { return this._parseAnsiColorChange(data); } + public parseAnsiColorChange(data: string): IColorEvent{ return this._parseAnsiColorChange(data); } /** * Promise based parse call to await the full resolve of given input data. @@ -54,10 +54,6 @@ class TestInputHandler extends InputHandler { prev = await result; } } - - public parseColorSpec(data: string): undefined | [number, number, number] { - return this._parseColorSpec(data); - } } describe('InputHandler', () => { @@ -1869,114 +1865,98 @@ describe('InputHandler', () => { }); }); describe('OSC', () => { - describe('parse xcolor names', () => { - it('rgb:// scheme in 4/8/12/16 bit', () => { - // 4 bit - assert.deepEqual(inputHandler.parseColorSpec('rgb:0/0/0'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:f/f/f'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:1/2/3'), [17, 34, 51]); - // 8 bit - assert.deepEqual(inputHandler.parseColorSpec('rgb:00/00/00'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:ff/ff/ff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:11/22/33'), [17, 34, 51]); - // 12 bit - assert.deepEqual(inputHandler.parseColorSpec('rgb:000/000/000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:fff/fff/fff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:111/222/333'), [17, 34, 51]); - // 16 bit - assert.deepEqual(inputHandler.parseColorSpec('rgb:0000/0000/0000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:ffff/ffff/ffff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('rgb:1111/2222/3333'), [17, 34, 51]); - }); - it('#RGB scheme in 4/8/12/16 bit', () => { - // 4 bit - assert.deepEqual(inputHandler.parseColorSpec('#000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('#fff'), [240, 240, 240]); - assert.deepEqual(inputHandler.parseColorSpec('#123'), [16, 32, 48]); - // 8 bit - assert.deepEqual(inputHandler.parseColorSpec('#000000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('#ffffff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('#112233'), [17, 34, 51]); - // 12 bit - assert.deepEqual(inputHandler.parseColorSpec('#000000000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('#fffffffff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('#111222333'), [17, 34, 51]); - // 16 bit - assert.deepEqual(inputHandler.parseColorSpec('#000000000000'), [0, 0, 0]); - assert.deepEqual(inputHandler.parseColorSpec('#ffffffffffff'), [255, 255, 255]); - assert.deepEqual(inputHandler.parseColorSpec('#111122223333'), [17, 34, 51]); - }); - it('supports upper case', () => { - assert.deepEqual(inputHandler.parseColorSpec('RGB:0/A/F'), [0, 170, 255]); - assert.deepEqual(inputHandler.parseColorSpec('#FFF'), [240, 240, 240]); - }); - it('does not parse illegal combinations', () => { - // shifting bit width - assert.equal(inputHandler.parseColorSpec('rgb:0/11/222'), undefined); - // unsupported scheme - assert.equal(inputHandler.parseColorSpec('rgbi:00/11/22'), undefined); - // broken # specifier - assert.equal(inputHandler.parseColorSpec('#aabbbcc'), undefined); - // out of range - assert.equal(inputHandler.parseColorSpec('#aabbgg'), undefined); - assert.equal(inputHandler.parseColorSpec('rgb:aa/bb/gg'), undefined); - }); - }); - - it('4: should parse correct Ansi color change data', () => { // this is testing a private method const event = inputHandler.parseAnsiColorChange('19;rgb:a1/b2/c3'); assert.isNotNull(event); - assert.deepEqual(event!.requests[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); + assert.deepEqual(event![0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); }); - it('4: should ignore incorrect Ansi color change data', () => { // this is testing a private method - assert.isNull(inputHandler.parseAnsiColorChange('17;rgb:a/b/c')); - assert.isNull(inputHandler.parseAnsiColorChange('17;rgb:#aabbcc')); - assert.isNull(inputHandler.parseAnsiColorChange('17;rgba:aa/bb/cc')); - assert.isNull(inputHandler.parseAnsiColorChange('rgb:aa/bb/cc')); + assert.equal(inputHandler.parseAnsiColorChange('17;rgb:a/b/c').length, 0); + assert.equal(inputHandler.parseAnsiColorChange('17;rgb:#aabbcc').length, 0); + assert.equal(inputHandler.parseAnsiColorChange('17;rgba:aa/bb/cc').length, 0); + assert.equal(inputHandler.parseAnsiColorChange('rgb:aa/bb/cc').length, 0); }); - it('4: should parse a list of Ansi color changes', () => { // this is testing a private method const event = inputHandler.parseAnsiColorChange('19;rgb:a1/b2/c3;17;rgb:00/11/22;255;rgb:01/ef/2d'); - assert.isNotNull(event); - assert.equal(event!.requests.length, 3); - assert.deepEqual(event!.requests[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); - assert.deepEqual(event!.requests[1], { index: 17, color: [0x00, 0x11, 0x22] }); - assert.deepEqual(event!.requests[2], { index: 255, color: [0x01, 0xef, 0x2d] }); + assert.equal(event.length, 3); + assert.deepEqual(event[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); + assert.deepEqual(event[1], { index: 17, color: [0x00, 0x11, 0x22] }); + assert.deepEqual(event[2], { index: 255, color: [0x01, 0xef, 0x2d] }); }); it('4: should ignore incorrect colors in a list of Ansi color changes', () => { // this is testing a private method const event = inputHandler.parseAnsiColorChange('19;rgb:a1/b2/c3;17;rgb:WR/ON/G;255;rgb:01/ef/2d'); - assert.equal(event!.requests.length, 2); - assert.deepEqual(event!.requests[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); - assert.deepEqual(event!.requests[1], { index: 255, color: [0x01, 0xef, 0x2d] }); + assert.equal(event.length, 2); + assert.deepEqual(event[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); + assert.deepEqual(event[1], { index: 255, color: [0x01, 0xef, 0x2d] }); }); it('4: should be case insensitive when parsing Ansi color changes', () => { // this is testing a private method const event = inputHandler.parseAnsiColorChange('19;rGb:A1/b2/C3'); - assert.equal(event!.requests.length, 1); - assert.deepEqual(event!.requests[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); + assert.equal(event.length, 1); + assert.deepEqual(event[0], { index: 19, color: [0xa1, 0xb2, 0xc3] }); }); it('4: should fire event on Ansi color change', async () => { return new Promise(async r => { inputHandler.onColor(e => { - assert.isNotNull(e); - assert.isNotNull(e!.requests); - assert.deepEqual(e!.requests[0], { index: 17, color: [0x1a, 0x2b, 0x3c] }); - assert.deepEqual(e!.requests[1], { index: 12, color: [0x11, 0x22, 0x33] }); + assert.deepEqual(e[0], { index: 17, color: [0x1a, 0x2b, 0x3c] }); + assert.deepEqual(e[1], { index: 12, color: [0x11, 0x22, 0x33] }); r(); }); await inputHandler.parseP('\x1b]4;17;rgb:1a/2b/3c;12;rgb:11/22/33\x1b\\'); }); }); + + it('10: should create appropriate events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single foreground query --> color undefined + await inputHandler.parseP('\x1b]10;?\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.FOREGROUND }]]); + stack.length = 0; + // OSC with multiple values maps to OSC 10 & OSC 11 + await inputHandler.parseP('\x1b]10;?;?;?;?\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.FOREGROUND }], [{ index: ColorIndex.BACKGROUND }]]); + stack.length = 0; + // set foreground color events + await inputHandler.parseP('\x1b]10;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.FOREGROUND, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]10;#aabbcc\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.FOREGROUND, color: [170, 187, 204] }]]); + stack.length = 0; + // set FG and BG at once + await inputHandler.parseP('\x1b]10;rgb:aa/bb/cc;#001122\x07'); + assert.deepEqual(stack, [ + [{ index: ColorIndex.FOREGROUND, color: [170, 187, 204] }], + [{ index: ColorIndex.BACKGROUND, color: [0, 17, 34] }], + ]); + }); + it('11: should create appropriate events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single foreground query --> color undefined + await inputHandler.parseP('\x1b]11;?\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.BACKGROUND }]]); + stack.length = 0; + // OSC 11 with multiple values creates only one BG event + await inputHandler.parseP('\x1b]11;?;?;?;?\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.BACKGROUND }]]); + stack.length = 0; + // set background color events + await inputHandler.parseP('\x1b]11;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.BACKGROUND, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]11;#aabbcc\x07'); + assert.deepEqual(stack, [[{ index: ColorIndex.BACKGROUND, color: [170, 187, 204] }]]); + }); }); // issue #3362 and #2979 diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 5384bf8d..a8d15db1 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -4,7 +4,7 @@ * @license MIT */ -import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack } from 'common/Types'; +import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex } from 'common/Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; @@ -21,6 +21,7 @@ import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowSe import { OscHandler } from 'common/parser/OscParser'; import { DcsHandler } from 'common/parser/DcsParser'; import { IBuffer } from 'common/buffer/Types'; +import { parseColor } from 'common/input/XParseColor'; /** * Map collect to glevel. Used in `selectCharset`. @@ -403,9 +404,9 @@ export class InputHandler extends Disposable implements IInputHandler { // 6 - Enable/disable Special Color Number c // 7 - current directory? (not in xterm spec, see https://gitlab.com/gnachman/iterm2/issues/3939) // 10 - Change VT100 text foreground color to Pt. - this._parser.registerOscHandler(10, new OscHandler(data => this.queryOrSetFgColor(data))); + this._parser.registerOscHandler(10, new OscHandler(data => this.setOrReportFgColor(data))); // 11 - Change VT100 text background color to Pt. - this._parser.registerOscHandler(11, new OscHandler(data => this.queryOrSetBgColor(data))); + this._parser.registerOscHandler(11, new OscHandler(data => this.setOrReportBgColor(data))); // 12 - Change text cursor color to Pt. // 13 - Change mouse foreground color to Pt. // 14 - Change mouse background color to Pt. @@ -2841,14 +2842,13 @@ export class InputHandler extends Disposable implements IInputHandler { return true; } - protected _parseAnsiColorChange(data: string): IColorEvent | null { - const result: IColorEvent = { requests: [] }; + protected _parseAnsiColorChange(data: string): IColorEvent { + const result: IColorEvent = []; // example data: 5;rgb:aa/bb/cc const regex = /(\d+);rgb:([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})/gi; let match; - while ((match = regex.exec(data)) !== null) { - result.requests.push({ + result.push({ index: parseInt(match[1]), color: [ parseInt(match[2], 16), @@ -2857,58 +2857,9 @@ export class InputHandler extends Disposable implements IInputHandler { ] }); } - - if (result.requests.length === 0) { - return null; - } - return result; } - /** - * Parse color spec to RGB values (8 bit per channel). - * See `man xparsecolor` for details about certain format specifications. - * - * Supported formats: - * - rgb:// with , , in h | hh | hhh | hhhh - * - #RGB, #RRGGBB, #RRRGGGBBB, #RRRRGGGGBBBB - * - * All other formats like rgbi: or device-independent string specifications - * with float numbering are not supported. - */ - protected _parseColorSpec(data: string): [number, number, number] | undefined { - if (!data) return; - // also handle uppercases - let low = data.toLowerCase(); - if (low.indexOf('rgb:') === 0) { - // 'rgb:' specifier - low = low.slice(4); - const rex = /^([\da-f]{1})\/([\da-f]{1})\/([\da-f]{1})$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/; - const m = rex.exec(low); - if (m) { - const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535; - return [ - Math.round(parseInt(m[1] || m[4] || m[7] || m[10], 16) / base * 255), - Math.round(parseInt(m[2] || m[5] || m[8] || m[11], 16) / base * 255), - Math.round(parseInt(m[3] || m[6] || m[9] || m[12], 16) / base * 255) - ]; - } - } else if (low.indexOf('#') === 0) { - // '#' specifier - low = low.slice(1); - const rex = /^[\da-f]+$/; - if (rex.exec(low) && [3, 6, 9, 12].includes(low.length)) { - const adv = low.length / 3; - const result: [number, number, number] = [0, 0, 0]; - for (let i = 0; i < 3; ++i) { - const c = parseInt(low.slice(adv * i, adv * i + adv), 16); - result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8; - } - return result; - } - } - } - /** * OSC 4; ; ST (set ANSI color to ) * @@ -2918,7 +2869,7 @@ export class InputHandler extends Disposable implements IInputHandler { */ public setAnsiColor(data: string): boolean { const event = this._parseAnsiColorChange(data); - if (event) { + if (event.length) { this._onColor.fire(event); } else { @@ -2940,6 +2891,8 @@ export class InputHandler extends Disposable implements IInputHandler { * - `#RRRGGGBBB` - 12 bits per channel, truncated to `#RRGGBB` * - `#RRRRGGGGBBBB` - 16 bits per channel, truncated to `#RRGGBB` * + * **Note:** X11 named colors are currently unsupported. + * * If `Pt` contains `?` instead of a color specification, the terminal * returns a sequence with the current default foreground color * (use that sequence to restore the color after changes). @@ -2947,21 +2900,20 @@ export class InputHandler extends Disposable implements IInputHandler { * **Note:** Other than xterm, xterm.js does not support OSC 12 - 19. * Therefore stacking multiple `Pt` separated by `;` only works for the first two entries. */ - public queryOrSetFgColor(data: string): boolean { - // note: data may contain multiple ? or color names separated with ; - // Multiple values will map through to OSC 10 - 19, but we only support 10 and 11 currently, - // thus truncate to max. 2 entries. - const slots = data.split(';').slice(0, 2); + public setOrReportFgColor(data: string): boolean { + // Note: data may contain multiple values separated with ; mapping to OSC 10 - 19 + const slots = data.split(';'); if (slots[0] === '?') { - this._onColor.fire({ requests: [{ index: 256, color: '?' }] }); + this._onColor.fire([{ index: ColorIndex.FOREGROUND }]); } else { - const color = this._parseColorSpec(slots[0]); + const color = parseColor(slots[0]); if (color) { - this._onColor.fire({ requests: [{ index: 256, color }] }); + this._onColor.fire([{ index: ColorIndex.FOREGROUND, color }]); } } - if (slots.length === 2) { - this.queryOrSetBgColor(slots[1]); + // forward second slot to OSC 11 (higher slots are not supported) + if (slots.length > 1) { + this.setOrReportBgColor(slots[1]); } return true; } @@ -2971,14 +2923,14 @@ export class InputHandler extends Disposable implements IInputHandler { * * @vt: #Y OSC 11 "Set or query default background color" "OSC 11 ; Pt BEL" "Same as OSC 10, but for default background." */ - public queryOrSetBgColor(data: string): boolean { - const slots = data.split(';').slice(0, 1); + public setOrReportBgColor(data: string): boolean { + const slots = data.split(';'); if (slots[0] === '?') { - this._onColor.fire({ requests: [{ index: 257, color: '?' }] }); + this._onColor.fire([{ index: ColorIndex.BACKGROUND }]); } else { - const color = this._parseColorSpec(slots[0]); + const color = parseColor(slots[0]); if (color) { - this._onColor.fire({ requests: [{ index: 257, color }] }); + this._onColor.fire([{ index: ColorIndex.BACKGROUND, color }]); } } return true; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 9cc6dbe7..b38a0643 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -348,17 +348,20 @@ export interface IWindowOptions { setWinLines?: boolean; } -export interface IColorEventColor { - index: number; - color: [number, number, number] | '?'; +// color events from common, used for OSC 4/10/11 +export const enum ColorIndex { + FOREGROUND = 256, + BACKGROUND = 257 } +export interface IColorReportRequest { + index: ColorIndex; + color?: IColorRGB; +} +export interface IColorSetRequest extends IColorReportRequest { + color: IColorRGB; +} +export type IColorEvent = (IColorReportRequest | IColorSetRequest)[]; -/** - * Event fired for OSC 4 command - to change ANSI color based on its index. - */ -export interface IColorEvent { - requests: IColorEventColor[]; -} /** * Calls the parser and handles actions generated by the parser. diff --git a/src/common/input/XParseColor.test.ts b/src/common/input/XParseColor.test.ts new file mode 100644 index 00000000..4a73cdbb --- /dev/null +++ b/src/common/input/XParseColor.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2021 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { parseColor, toRgbString } from 'common/input/XParseColor'; + +describe('XParseColor', () => { + describe('parseColor', () => { + it('rgb:// scheme in 4/8/12/16 bit', () => { + // 4 bit + assert.deepEqual(parseColor('rgb:0/0/0'), [0, 0, 0]); + assert.deepEqual(parseColor('rgb:f/f/f'), [255, 255, 255]); + assert.deepEqual(parseColor('rgb:1/2/3'), [17, 34, 51]); + // 8 bit + assert.deepEqual(parseColor('rgb:00/00/00'), [0, 0, 0]); + assert.deepEqual(parseColor('rgb:ff/ff/ff'), [255, 255, 255]); + assert.deepEqual(parseColor('rgb:11/22/33'), [17, 34, 51]); + // 12 bit + assert.deepEqual(parseColor('rgb:000/000/000'), [0, 0, 0]); + assert.deepEqual(parseColor('rgb:fff/fff/fff'), [255, 255, 255]); + assert.deepEqual(parseColor('rgb:111/222/333'), [17, 34, 51]); + // 16 bit + assert.deepEqual(parseColor('rgb:0000/0000/0000'), [0, 0, 0]); + assert.deepEqual(parseColor('rgb:ffff/ffff/ffff'), [255, 255, 255]); + assert.deepEqual(parseColor('rgb:1111/2222/3333'), [17, 34, 51]); + }); + it('#RGB scheme in 4/8/12/16 bit', () => { + // 4 bit + assert.deepEqual(parseColor('#000'), [0, 0, 0]); + assert.deepEqual(parseColor('#fff'), [240, 240, 240]); + assert.deepEqual(parseColor('#123'), [16, 32, 48]); + // 8 bit + assert.deepEqual(parseColor('#000000'), [0, 0, 0]); + assert.deepEqual(parseColor('#ffffff'), [255, 255, 255]); + assert.deepEqual(parseColor('#112233'), [17, 34, 51]); + // 12 bit + assert.deepEqual(parseColor('#000000000'), [0, 0, 0]); + assert.deepEqual(parseColor('#fffffffff'), [255, 255, 255]); + assert.deepEqual(parseColor('#111222333'), [17, 34, 51]); + // 16 bit + assert.deepEqual(parseColor('#000000000000'), [0, 0, 0]); + assert.deepEqual(parseColor('#ffffffffffff'), [255, 255, 255]); + assert.deepEqual(parseColor('#111122223333'), [17, 34, 51]); + }); + it('supports upper case', () => { + assert.deepEqual(parseColor('RGB:0/A/F'), [0, 170, 255]); + assert.deepEqual(parseColor('#FFF'), [240, 240, 240]); + }); + it('does not parse illegal combinations', () => { + // shifting bit width + assert.equal(parseColor('rgb:0/11/222'), undefined); + // unsupported scheme + assert.equal(parseColor('rgbi:00/11/22'), undefined); + // broken # specifier + assert.equal(parseColor('#aabbbcc'), undefined); + // out of range + assert.equal(parseColor('#aabbgg'), undefined); + assert.equal(parseColor('rgb:aa/bb/gg'), undefined); + }); + }); + describe('toXColorRgb', () => { + it('rgb:// scheme in 4/8/12/16 bit', () => { + // 4 bit + assert.equal(toRgbString(parseColor('rgb:0/0/0')!, 4), 'rgb:0/0/0'); + assert.equal(toRgbString(parseColor('rgb:f/f/f')!, 4), 'rgb:f/f/f'); + assert.equal(toRgbString(parseColor('rgb:1/2/3')!, 4), 'rgb:1/2/3'); + // 8 bit + assert.equal(toRgbString(parseColor('rgb:00/00/00')!, 8), 'rgb:00/00/00'); + assert.equal(toRgbString(parseColor('rgb:ff/ff/ff')!, 8), 'rgb:ff/ff/ff'); + assert.equal(toRgbString(parseColor('rgb:11/22/33')!, 8), 'rgb:11/22/33'); + // 12 bit + assert.equal(toRgbString(parseColor('rgb:000/000/000')!, 12), 'rgb:000/000/000'); + assert.equal(toRgbString(parseColor('rgb:fff/fff/fff')!, 12), 'rgb:fff/fff/fff'); + assert.equal(toRgbString(parseColor('rgb:111/222/333')!, 12), 'rgb:111/222/333'); + // 16 bit + assert.equal(toRgbString(parseColor('rgb:0000/0000/0000')!, 16), 'rgb:0000/0000/0000'); + assert.equal(toRgbString(parseColor('rgb:ffff/ffff/ffff')!, 16), 'rgb:ffff/ffff/ffff'); + assert.equal(toRgbString(parseColor('rgb:1111/2222/3333')!, 16), 'rgb:1111/2222/3333'); + }); + it('defaults to 16 bit output', () => { + assert.equal(toRgbString(parseColor('rgb:1/2/3')!), 'rgb:1111/2222/3333'); + assert.equal(toRgbString(parseColor('rgb:11/22/33')!), 'rgb:1111/2222/3333'); + assert.equal(toRgbString(parseColor('rgb:111/222/333')!), 'rgb:1111/2222/3333'); + assert.equal(toRgbString(parseColor('rgb:123/123/123')!), 'rgb:1212/1212/1212'); + }); + it('reduces colors to 8 bit resolution', () => { + assert.equal(toRgbString(parseColor('rgb:123/123/123')!, 12), 'rgb:121/121/121'); + assert.equal(toRgbString(parseColor('rgb:1234/1234/1234')!, 16), 'rgb:1212/1212/1212'); + }); + }); +}); diff --git a/src/common/input/XParseColor.ts b/src/common/input/XParseColor.ts new file mode 100644 index 00000000..922bf8a9 --- /dev/null +++ b/src/common/input/XParseColor.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2021 The xterm.js authors. All rights reserved. + * @license MIT + */ + + +// 'rgb:' rule - matching: r/g/b | rr/gg/bb | rrr/ggg/bbb | rrrr/gggg/bbbb (hex digits) +const RGB_REX = /^([\da-f]{1})\/([\da-f]{1})\/([\da-f]{1})$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/; +// '#...' rule - matching any hex digits +const HASH_REX = /^[\da-f]+$/; + +/** + * Parse color spec to RGB values (8 bit per channel). + * See `man xparsecolor` for details about certain format specifications. + * + * Supported formats: + * - rgb:// with , , in h | hh | hhh | hhhh + * - #RGB, #RRGGBB, #RRRGGGBBB, #RRRRGGGGBBBB + * + * All other formats like rgbi: or device-independent string specifications + * with float numbering are not supported. + */ +export function parseColor(data: string): [number, number, number] | undefined { + if (!data) return; + // also handle uppercases + let low = data.toLowerCase(); + if (low.indexOf('rgb:') === 0) { + // 'rgb:' specifier + low = low.slice(4); + const m = RGB_REX.exec(low); + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535; + return [ + Math.round(parseInt(m[1] || m[4] || m[7] || m[10], 16) / base * 255), + Math.round(parseInt(m[2] || m[5] || m[8] || m[11], 16) / base * 255), + Math.round(parseInt(m[3] || m[6] || m[9] || m[12], 16) / base * 255) + ]; + } + } else if (low.indexOf('#') === 0) { + // '#' specifier + low = low.slice(1); + if (HASH_REX.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3; + const result: [number, number, number] = [0, 0, 0]; + for (let i = 0; i < 3; ++i) { + const c = parseInt(low.slice(adv * i, adv * i + adv), 16); + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8; + } + return result; + } + } + // FIXME: Once #3530 is resolved, implement named colors. +} + +// pad hex output to requested bit width +function pad(n: number, bits: number): string { + const s = n.toString(16); + const s2 = s.length < 2 ? '0' + s : s; + switch (bits) { + case 4: + return s[0]; + case 8: + return s2; + case 12: + return (s2 + s2).slice(0, 3); + default: + return s2 + s2; + } +} + +/** + * Convert a given color to rgb:../../.. string of `bits` depth. + */ +export function toRgbString(color: [number, number, number], bits: number = 16): string { + const [r, g, b] = color; + return `rgb:${pad(r, bits)}/${pad(g, bits)}/${pad(b, bits)}`; +}