diff --git a/src/browser/Color.ts b/src/browser/Color.ts index c43c5eb5..32e311db 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'; /** * Helper functions where the source type is "channels" (individual color channels as numbers). @@ -17,6 +18,8 @@ export namespace channels { } export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number { + // Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32 + // on LE systems, before it can be used for direct 32-bit buffer writes. // >>> 0 forces an unsigned int return (r << 24 | g << 16 | b << 8 | a) >>> 0; } @@ -81,6 +84,10 @@ export namespace color { rgba: channels.toRgba(r, g, b, a) }; } + + export function toColorRGB(color: IColor): IColorRGB { + return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF]; + } } /** @@ -197,6 +204,7 @@ export namespace rgba { return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0; } + // FIXME: Move this to channels NS? export function toChannels(value: number): [number, number, number, number] { return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; } diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index b6950d28..b4b57c67 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -7,6 +7,16 @@ import { IColorManager, IColor, IColorSet, IColorContrastCache } from 'browser/T import { ITheme } from 'common/services/Services'; import { channels, color, css } from 'browser/Color'; import { ColorContrastCache } from 'browser/ColorContrastCache'; +import { ColorIndex } from 'common/Types'; + + +interface IRestoreColorSet { + foreground: IColor; + background: IColor; + cursor: IColor; + ansi: IColor[]; +} + const DEFAULT_FOREGROUND = css.toColor('#ffffff'); const DEFAULT_BACKGROUND = css.toColor('#000000'); @@ -73,6 +83,7 @@ export class ColorManager implements IColorManager { private _ctx: CanvasRenderingContext2D; private _litmusColor: CanvasGradient; private _contrastCache: IColorContrastCache; + private _restoreColors!: IRestoreColorSet; constructor(document: Document, public allowTransparency: boolean) { const canvas = document.createElement('canvas'); @@ -96,6 +107,7 @@ export class ColorManager implements IColorManager { ansi: DEFAULT_ANSI_COLORS.slice(), contrastCache: this._contrastCache }; + this._updateRestoreColors(); } public onOptionsChange(key: string): void { @@ -142,6 +154,39 @@ export class ColorManager implements IColorManager { this.colors.ansi[15] = this._parseColor(theme.brightWhite, DEFAULT_ANSI_COLORS[15]); // Clear our the cache this._contrastCache.clear(); + this._updateRestoreColors(); + } + + public restoreColor(slot?: ColorIndex): void { + // unset slot restores all ansi colors + if (slot === undefined) { + for (let i = 0; i < this._restoreColors.ansi.length; ++i) { + this.colors.ansi[i] = this._restoreColors.ansi[i]; + } + return; + } + switch (slot) { + case ColorIndex.FOREGROUND: + this.colors.foreground = this._restoreColors.foreground; + break; + case ColorIndex.BACKGROUND: + this.colors.background = this._restoreColors.background; + break; + case ColorIndex.CURSOR: + this.colors.cursor = this._restoreColors.cursor; + break; + default: + this.colors.ansi[slot] = this._restoreColors.ansi[slot]; + } + } + + private _updateRestoreColors(): void { + this._restoreColors = { + foreground: this.colors.foreground, + background: this.colors.background, + cursor: this.colors.cursor, + ansi: [...this.colors.ansi] + }; } private _parseColor( diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 68291f64..4e39b3f4 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, ScrollSource, IAnsiColorChangeEvent } from 'common/Types'; +import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; @@ -52,9 +52,9 @@ import { MouseService } from 'browser/services/MouseService'; import { Linkifier2 } from 'browser/Linkifier2'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; import { CoreTerminal } from 'common/CoreTerminal'; -import { rgba } from 'browser/Color'; +import { color, rgba } from 'browser/Color'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; -import { ITerminalOptions } from 'common/services/Services'; +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; @@ -164,7 +164,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this._inputHandler.onRequestSendFocus(() => this._reportFocus())); this.register(this._inputHandler.onRequestReset(() => this.reset())); this.register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type))); - this.register(this._inputHandler.onAnsiColorChange((event) => this._changeAnsiColor(event))); + this.register(this._inputHandler.onColor((event) => this._handleColorEvent(event))); this.register(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove)); this.register(forwardEvent(this._inputHandler.onTitleChange, this._onTitleChange)); this.register(forwardEvent(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); @@ -174,17 +174,55 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this._bufferService.onResize(e => this._afterResize(e.cols, e.rows))); } - private _changeAnsiColor(event: IAnsiColorChangeEvent): void { - if (!this._colorManager) { return; } - - for (const ansiColor of event.colors) { - const color = rgba.toColor(ansiColor.red, ansiColor.green, ansiColor.blue); - - this._colorManager!.colors.ansi[ansiColor.colorIndex] = color; + /** + * Handle color event from inputhandler for OSC 4|104 | 10|110 | 11|111 | 12|112. + * An event from OSC 4|104 may contain multiple set or report requests, and multiple + * or none restore requests (resetting all), + * while an event from OSC 10|110 | 11|111 | 12|112 always contains a single request. + */ + private _handleColorEvent(event: IColorEvent): void { + if (!this._colorManager) return; + for (const req of event) { + let acc: 'foreground' | 'background' | 'cursor' | 'ansi' | undefined = undefined; + let ident = ''; + switch (req.index) { + case ColorIndex.FOREGROUND: // OSC 10 | 110 + acc = 'foreground'; + ident = '10'; + break; + case ColorIndex.BACKGROUND: // OSC 11 | 111 + acc = 'background'; + ident = '11'; + break; + case ColorIndex.CURSOR: // OSC 12 | 112 + acc = 'cursor'; + ident = '12'; + break; + default: // OSC 4 | 104 + // we can skip the [0..255] range check here (already done in inputhandler) + acc = 'ansi'; + ident = '4;' + req.index; + } + if (acc) { + switch (req.type) { + case ColorRequestType.REPORT: + const channels = color.toColorRGB(acc === 'ansi' + ? this._colorManager.colors.ansi[req.index] + : this._colorManager.colors[acc]); + this.coreService.triggerDataEvent(`${C0.ESC}]${ident};${toRgbString(channels)}${C0.BEL}`); + break; + case ColorRequestType.SET: + if (acc === 'ansi') this._colorManager.colors.ansi[req.index] = rgba.toColor(...req.color); + else this._colorManager.colors[acc] = rgba.toColor(...req.color); + break; + case ColorRequestType.RESTORE: + this._colorManager.restoreColor(req.index); + break; + } + } } - - 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 bff7cbe9..eac41052 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, IAnsiColorChangeEvent } from 'common/Types'; +import { IBufferLine, IAttributeData, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; @@ -17,7 +17,7 @@ import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { clone } from 'common/Clone'; import { BufferService } from 'common/services/BufferService'; import { CoreService } from 'common/services/CoreService'; -import { OscHandler } from 'common/parser/OscParser'; + function getCursor(bufferService: IBufferService): number[] { return [ @@ -41,7 +41,6 @@ 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): IAnsiColorChangeEvent | null { return this._parseAnsiColorChange(data); } /** * Promise based parse call to await the full resolve of given input data. @@ -1915,58 +1914,144 @@ describe('InputHandler', () => { }); }); describe('OSC', () => { - 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!.colors[0], { colorIndex: 19, red: 0xa1, green: 0xb2, blue: 0xc3 }); + it('4: query color events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single color query + await inputHandler.parseP('\x1b]4;0;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: 0 }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]4;123;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: 123 }]]); + stack.length = 0; + // multiple queries + await inputHandler.parseP('\x1b]4;0;?;123;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: 0 }, { type: ColorRequestType.REPORT, index: 123 }]]); + stack.length = 0; + }); + it('4: set color events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single color query + await inputHandler.parseP('\x1b]4;0;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]4;123;#aabbcc\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 123, color: [170, 187, 204] }]]); + stack.length = 0; + // multiple queries + await inputHandler.parseP('\x1b]4;0;rgb:aa/bb/cc;123;#001122\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [170, 187, 204] }, { type: ColorRequestType.SET, index: 123, color: [0, 17, 34] }]]); + stack.length = 0; + }); + it('4: should ignore invalid values', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + await inputHandler.parseP('\x1b]4;0;rgb:aa/bb/cc;45;rgb:1/22/333;123;#001122\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: 0, color: [170, 187, 204] }, { type: ColorRequestType.SET, index: 123, color: [0, 17, 34] }]]); + stack.length = 0; + }); + it('104: restore events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + await inputHandler.parseP('\x1b]104;0\x07\x1b]104;43\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: 0 }], [{ type: ColorRequestType.RESTORE, index: 43 }]]); + stack.length = 0; + // multiple in one command + await inputHandler.parseP('\x1b]104;0;43\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: 0 }, { type: ColorRequestType.RESTORE, index: 43 }]]); + stack.length = 0; + // full ANSI table restore + await inputHandler.parseP('\x1b]104\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE}]]); }); - 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')); + it('10: FG set & query 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, [[{ type: ColorRequestType.REPORT, index: ColorIndex.FOREGROUND }]]); + stack.length = 0; + // OSC with multiple values maps to OSC 10 & OSC 11 & OSC 12 + await inputHandler.parseP('\x1b]10;?;?;?;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.FOREGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]); + stack.length = 0; + // set foreground color events + await inputHandler.parseP('\x1b]10;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]10;#aabbcc\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [170, 187, 204] }]]); + stack.length = 0; + // set FG, BG and cursor color at once + await inputHandler.parseP('\x1b]10;rgb:aa/bb/cc;#001122;rgb:12/34/56\x07'); + assert.deepEqual(stack, [ + [{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [170, 187, 204] }], + [{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [0, 17, 34] }], + [{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [18, 52, 86] }] + ]); }); - - 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!.colors.length, 3); - assert.deepEqual(event!.colors[0], { colorIndex: 19, red: 0xa1, green: 0xb2, blue: 0xc3 }); - assert.deepEqual(event!.colors[1], { colorIndex: 17, red: 0x00, green: 0x11, blue: 0x22 }); - assert.deepEqual(event!.colors[2], { colorIndex: 255, red: 0x01, green: 0xef, blue: 0x2d }); + it('110: restore FG color', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + await inputHandler.parseP('\x1b]110\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.FOREGROUND }]]); }); - 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!.colors.length, 2); - assert.deepEqual(event!.colors[0], { colorIndex: 19, red: 0xa1, green: 0xb2, blue: 0xc3 }); - assert.deepEqual(event!.colors[1], { colorIndex: 255, red: 0x01, green: 0xef, blue: 0x2d }); + it('11: BG set & query events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single background query --> color undefined + await inputHandler.parseP('\x1b]11;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }]]); + stack.length = 0; + // OSC 11 with multiple values creates only BG and cursor event + await inputHandler.parseP('\x1b]11;?;?;?;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]); + stack.length = 0; + // set background color events + await inputHandler.parseP('\x1b]11;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]11;#aabbcc\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [170, 187, 204] }]]); + stack.length = 0; + // set BG and cursor color at once + await inputHandler.parseP('\x1b]11;#001122;rgb:12/34/56\x07'); + assert.deepEqual(stack, [ + [{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [0, 17, 34] }], + [{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [18, 52, 86] }] + ]); }); - 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!.colors.length, 1); - assert.deepEqual(event!.colors[0], { colorIndex: 19, red: 0xa1, green: 0xb2, blue: 0xc3 }); + it('111: restore BG color', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + await inputHandler.parseP('\x1b]111\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.BACKGROUND }]]); }); - it('4: should fire event on Ansi color change', async () => { - return new Promise(async r => { - inputHandler.onAnsiColorChange(e => { - assert.isNotNull(e); - assert.isNotNull(e!.colors); - assert.deepEqual(e!.colors[0], { colorIndex: 17, red: 0x1a, green: 0x2b, blue: 0x3c }); - assert.deepEqual(e!.colors[1], { colorIndex: 12, red: 0x11, green: 0x22, blue: 0x33 }); - r(); - }); - await inputHandler.parseP('\x1b]4;17;rgb:1a/2b/3c;12;rgb:11/22/33\x1b\\'); - }); + it('12: cursor color set & query events', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + // single cursor query --> color undefined + await inputHandler.parseP('\x1b]12;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]); + stack.length = 0; + // OSC 12 with multiple values creates only cursor event + await inputHandler.parseP('\x1b]12;?;?;?;?\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]); + stack.length = 0; + // set cursor color events + await inputHandler.parseP('\x1b]12;rgb:01/02/03\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [1, 2, 3] }]]); + stack.length = 0; + await inputHandler.parseP('\x1b]12;#aabbcc\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [170, 187, 204] }]]); + }); + it('112: restore cursor color', async () => { + const stack: IColorEvent[] = []; + inputHandler.onColor(ev => stack.push(ev)); + await inputHandler.parseP('\x1b]112\x07'); + assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.CURSOR }]]); }); }); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 870ecf01..1ce3c836 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -4,7 +4,7 @@ * @license MIT */ -import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IAnsiColorChangeEvent, IParseStack } from 'common/Types'; +import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType } 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`. @@ -262,8 +263,8 @@ export class InputHandler extends Disposable implements IInputHandler { public get onScroll(): IEvent { return this._onScroll.event; } private _onTitleChange = new EventEmitter(); public get onTitleChange(): IEvent { return this._onTitleChange.event; } - private _onAnsiColorChange = new EventEmitter(); - public get onAnsiColorChange(): IEvent { return this._onAnsiColorChange.event; } + private _onColor = new EventEmitter(); + public get onColor(): IEvent { return this._onColor.event; } private _parseStack: IParseStack = { paused: false, @@ -398,13 +399,16 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.registerOscHandler(2, new OscHandler(data => this.setTitle(data))); // 3 - set property X in the form "prop=value" // 4 - Change Color Number - this._parser.registerOscHandler(4, new OscHandler(data => this.setAnsiColor(data))); + this._parser.registerOscHandler(4, new OscHandler(data => this.setOrReportIndexedColor(data))); // 5 - Change Special Color Number // 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.setOrReportFgColor(data))); // 11 - Change VT100 text background color to Pt. + this._parser.registerOscHandler(11, new OscHandler(data => this.setOrReportBgColor(data))); // 12 - Change text cursor color to Pt. + this._parser.registerOscHandler(12, new OscHandler(data => this.setOrReportCursorColor(data))); // 13 - Change mouse foreground color to Pt. // 14 - Change mouse background color to Pt. // 15 - Change Tektronix foreground color to Pt. @@ -417,11 +421,15 @@ export class InputHandler extends Disposable implements IInputHandler { // 51 - reserved for Emacs shell. // 52 - Manipulate Selection Data. // 104 ; c - Reset Color Number c. + this._parser.registerOscHandler(104, new OscHandler(data => this.restoreIndexedColor(data))); // 105 ; c - Reset Special Color Number c. // 106 ; c; f - Enable/disable Special Color Number c. // 110 - Reset VT100 text foreground color. + this._parser.registerOscHandler(110, new OscHandler(data => this.restoreFgColor(data))); // 111 - Reset VT100 text background color. + this._parser.registerOscHandler(111, new OscHandler(data => this.restoreBgColor(data))); // 112 - Reset text cursor color. + this._parser.registerOscHandler(112, new OscHandler(data => this.restoreCursorColor(data))); // 113 - Reset mouse foreground color. // 114 - Reset mouse background color. // 115 - Reset Tektronix foreground color. @@ -1977,7 +1985,7 @@ export class InputHandler extends Disposable implements IInputHandler { break; case 1049: // alt screen buffer cursor this.saveCursor(); - // FALL-THROUGH + // FALL-THROUGH case 47: // alt screen buffer case 1047: // alt screen buffer this._bufferService.buffers.activateAltBuffer(this._eraseAttrData()); @@ -2197,7 +2205,7 @@ export class InputHandler extends Disposable implements IInputHandler { this.restoreCursor(); break; case 1049: // alt screen buffer cursor - // FALL-THROUGH + // FALL-THROUGH case 47: // normal screen buffer case 1047: // normal screen buffer - clearing it first // Ensure the selection manager has the correct buffer @@ -2840,46 +2848,167 @@ export class InputHandler extends Disposable implements IInputHandler { return true; } - protected _parseAnsiColorChange(data: string): IAnsiColorChangeEvent | null { - const result: IAnsiColorChangeEvent = { colors: [] }; - // 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.colors.push({ - colorIndex: parseInt(match[1]), - red: parseInt(match[2], 16), - green: parseInt(match[3], 16), - blue: parseInt(match[4], 16) - }); - } - - if (result.colors.length === 0) { - return null; - } - - return result; - } - /** * OSC 4; ; ST (set ANSI color to ) * * @vt: #Y OSC 4 "Set ANSI color" "OSC 4 ; c ; spec BEL" "Change color number `c` to the color specified by `spec`." - * `c` is the color index between 0 and 255. `spec` color format is 'rgb:hh/hh/hh' where `h` are hexadecimal digits. - * There may be multipe c ; spec elements present in the same instruction, e.g. 1;rgb:10/20/30;2;rgb:a0/b0/c0. + * `c` is the color index between 0 and 255. The color format of `spec` is derived from `XParseColor` (see OSC 10 for supported formats). + * There may be multipe `c ; spec` pairs present in the same instruction. + * If `spec` contains `?` the terminal returns a sequence with the currently set color. */ - public setAnsiColor(data: string): boolean { - const event = this._parseAnsiColorChange(data); - if (event) { - this._onAnsiColorChange.fire(event); + public setOrReportIndexedColor(data: string): boolean { + const event: IColorEvent = []; + const slots = data.split(';'); + while (slots.length > 1) { + const idx = slots.shift() as string; + const spec = slots.shift() as string; + if (/^\d+$/.exec(idx)) { + const index = parseInt(idx); + if (0 <= index && index < 256) { + if (spec === '?') { + event.push({ type: ColorRequestType.REPORT, index }); + } else { + const color = parseColor(spec); + if (color) { + event.push({ type: ColorRequestType.SET, index, color }); + } + } + } + } } - else { - this._logService.warn(`Expected format ;rgb:// but got data: ${data}`); + if (event.length) { + this._onColor.fire(event); } return true; } + // special colors - OSC 10 | 11 | 12 + private _specialColors = [ColorIndex.FOREGROUND, ColorIndex.BACKGROUND, ColorIndex.CURSOR]; + + /** + * Apply colors requests for special colors in OSC 10 | 11 | 12. + * Since these commands are stacking from multiple parameters, + * we handle them in a loop with an entry offset to `_specialColors`. + */ + private _setOrReportSpecialColor(data: string, offset: number): boolean { + const slots = data.split(';'); + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= this._specialColors.length) break; + if (slots[i] === '?') { + this._onColor.fire([{ type: ColorRequestType.REPORT, index: this._specialColors[offset] }]); + } else { + const color = parseColor(slots[i]); + if (color) { + this._onColor.fire([{ type: ColorRequestType.SET, index: this._specialColors[offset], color }]); + } + } + } + return true; + } + + /** + * OSC 10 ; | ST - set or query default foreground color + * + * @vt: #Y OSC 10 "Set or query default foreground color" "OSC 10 ; Pt BEL" "Set or query default foreground color." + * To set the color, the following color specification formats are supported: + * - `rgb://` for `, , ` in `h | hh | hhh | hhhh`, where + * `h` is a single hexadecimal digit (case insignificant). The different widths scale + * from 4 bit (`h`) to 16 bit (`hhhh`) and get converted to 8 bit (`hh`). + * - `#RGB` - 4 bits per channel, expanded to `#R0G0B0` + * - `#RRGGBB` - 8 bits per channel + * - `#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). + * + * **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 setOrReportFgColor(data: string): boolean { + return this._setOrReportSpecialColor(data, 0); + } + + /** + * OSC 11 ; | ST - set or query default background color + * + * @vt: #Y OSC 11 "Set or query default background color" "OSC 11 ; Pt BEL" "Same as OSC 10, but for default background." + */ + public setOrReportBgColor(data: string): boolean { + return this._setOrReportSpecialColor(data, 1); + } + + /** + * OSC 12 ; | ST - set or query default cursor color + * + * @vt: #Y OSC 12 "Set or query default cursor color" "OSC 12 ; Pt BEL" "Same as OSC 10, but for default cursor color." + */ + public setOrReportCursorColor(data: string): boolean { + return this._setOrReportSpecialColor(data, 2); + } + + /** + * OSC 104 ; ST - restore ANSI color + * + * @vt: #Y OSC 104 "Reset ANSI color" "OSC 104 ; c BEL" "Reset color number `c` to themed color." + * `c` is the color index between 0 and 255. This function restores the default color for `c` as + * specified by the loaded theme. Any number of `c` parameters may be given. + * If no parameters are given, the entire indexed color table will be reset. + */ + public restoreIndexedColor(data: string): boolean { + if (!data) { + this._onColor.fire([{ type: ColorRequestType.RESTORE }]); + return true; + } + const event: IColorEvent = []; + const slots = data.split(';'); + for (let i = 0; i < slots.length; ++i) { + if (/^\d+$/.exec(slots[i])) { + const index = parseInt(slots[i]); + if (0 <= index && index < 256) { + event.push({ type: ColorRequestType.RESTORE, index }); + } + } + } + if (event.length) { + this._onColor.fire(event); + } + return true; + } + + /** + * OSC 110 ST - restore default foreground color + * + * @vt: #Y OSC 110 "Restore default foreground color" "OSC 110 BEL" "Restore default foreground to themed color." + */ + public restoreFgColor(data: string): boolean { + this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.FOREGROUND }]); + return true; + } + + /** + * OSC 111 ST - restore default background color + * + * @vt: #Y OSC 111 "Restore default background color" "OSC 111 BEL" "Restore default background to themed color." + */ + public restoreBgColor(data: string): boolean { + this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.BACKGROUND }]); + return true; + } + + /** + * OSC 112 ST - restore default cursor color + * + * @vt: #Y OSC 112 "Restore default cursor color" "OSC 112 BEL" "Restore default cursor to themed color." + */ + public restoreCursorColor(data: string): boolean { + this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.CURSOR }]); + return true; + } + /** * ESC E * C1.NEL diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 3af7e2dc..fee426e1 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -349,19 +349,32 @@ export interface IWindowOptions { setWinLines?: boolean; } -export interface IAnsiColorChangeEventColor { - colorIndex: number; - red: number; - green: number; - blue: number; +// color events from common, used for OSC 4/10/11/12 and 104/110/111/112 +export const enum ColorRequestType { + REPORT = 0, + SET = 1, + RESTORE = 2 } +export const enum ColorIndex { + FOREGROUND = 256, + BACKGROUND = 257, + CURSOR = 258 +} +export interface IColorReportRequest { + type: ColorRequestType.REPORT; + index: ColorIndex; +} +export interface IColorSetRequest { + type: ColorRequestType.SET; + index: ColorIndex; + color: IColorRGB; +} +export interface IColorRestoreRequest { + type: ColorRequestType.RESTORE; + index?: ColorIndex; +} +export type IColorEvent = (IColorReportRequest | IColorSetRequest | IColorRestoreRequest)[]; -/** - * Event fired for OSC 4 command - to change ANSI color based on its index. - */ -export interface IAnsiColorChangeEvent { - colors: IAnsiColorChangeEventColor[]; -} /** * Calls the parser and handles actions generated by the parser. @@ -376,80 +389,89 @@ export interface IInputHandler { registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; - /** C0 BEL */ bell(): void; - /** C0 LF */ lineFeed(): void; - /** C0 CR */ carriageReturn(): void; - /** C0 BS */ backspace(): void; - /** C0 HT */ tab(): void; - /** C0 SO */ shiftOut(): void; - /** C0 SI */ shiftIn(): void; + /** C0 BEL */ bell(): boolean; + /** C0 LF */ lineFeed(): boolean; + /** C0 CR */ carriageReturn(): boolean; + /** C0 BS */ backspace(): boolean; + /** C0 HT */ tab(): boolean; + /** C0 SO */ shiftOut(): boolean; + /** C0 SI */ shiftIn(): boolean; + + /** CSI @ */ insertChars(params: IParams): boolean; + /** CSI SP @ */ scrollLeft(params: IParams): boolean; + /** CSI A */ cursorUp(params: IParams): boolean; + /** CSI SP A */ scrollRight(params: IParams): boolean; + /** CSI B */ cursorDown(params: IParams): boolean; + /** CSI C */ cursorForward(params: IParams): boolean; + /** CSI D */ cursorBackward(params: IParams): boolean; + /** CSI E */ cursorNextLine(params: IParams): boolean; + /** CSI F */ cursorPrecedingLine(params: IParams): boolean; + /** CSI G */ cursorCharAbsolute(params: IParams): boolean; + /** CSI H */ cursorPosition(params: IParams): boolean; + /** CSI I */ cursorForwardTab(params: IParams): boolean; + /** CSI J */ eraseInDisplay(params: IParams): boolean; + /** CSI K */ eraseInLine(params: IParams): boolean; + /** CSI L */ insertLines(params: IParams): boolean; + /** CSI M */ deleteLines(params: IParams): boolean; + /** CSI P */ deleteChars(params: IParams): boolean; + /** CSI S */ scrollUp(params: IParams): boolean; + /** CSI T */ scrollDown(params: IParams, collect?: string): boolean; + /** CSI X */ eraseChars(params: IParams): boolean; + /** CSI Z */ cursorBackwardTab(params: IParams): boolean; + /** CSI ` */ charPosAbsolute(params: IParams): boolean; + /** CSI a */ hPositionRelative(params: IParams): boolean; + /** CSI b */ repeatPrecedingCharacter(params: IParams): boolean; + /** CSI c */ sendDeviceAttributesPrimary(params: IParams): boolean; + /** CSI > c */ sendDeviceAttributesSecondary(params: IParams): boolean; + /** CSI d */ linePosAbsolute(params: IParams): boolean; + /** CSI e */ vPositionRelative(params: IParams): boolean; + /** CSI f */ hVPosition(params: IParams): boolean; + /** CSI g */ tabClear(params: IParams): boolean; + /** CSI h */ setMode(params: IParams, collect?: string): boolean; + /** CSI l */ resetMode(params: IParams, collect?: string): boolean; + /** CSI m */ charAttributes(params: IParams): boolean; + /** CSI n */ deviceStatus(params: IParams, collect?: string): boolean; + /** CSI p */ softReset(params: IParams, collect?: string): boolean; + /** CSI q */ setCursorStyle(params: IParams, collect?: string): boolean; + /** CSI r */ setScrollRegion(params: IParams, collect?: string): boolean; + /** CSI s */ saveCursor(params: IParams): boolean; + /** CSI u */ restoreCursor(params: IParams): boolean; + /** CSI ' } */ insertColumns(params: IParams): boolean; + /** CSI ' ~ */ deleteColumns(params: IParams): boolean; - /** CSI @ */ insertChars(params: IParams): void; - /** CSI SP @ */ scrollLeft(params: IParams): void; - /** CSI A */ cursorUp(params: IParams): void; - /** CSI SP A */ scrollRight(params: IParams): void; - /** CSI B */ cursorDown(params: IParams): void; - /** CSI C */ cursorForward(params: IParams): void; - /** CSI D */ cursorBackward(params: IParams): void; - /** CSI E */ cursorNextLine(params: IParams): void; - /** CSI F */ cursorPrecedingLine(params: IParams): void; - /** CSI G */ cursorCharAbsolute(params: IParams): void; - /** CSI H */ cursorPosition(params: IParams): void; - /** CSI I */ cursorForwardTab(params: IParams): void; - /** CSI J */ eraseInDisplay(params: IParams): void; - /** CSI K */ eraseInLine(params: IParams): void; - /** CSI L */ insertLines(params: IParams): void; - /** CSI M */ deleteLines(params: IParams): void; - /** CSI P */ deleteChars(params: IParams): void; - /** CSI S */ scrollUp(params: IParams): void; - /** CSI T */ scrollDown(params: IParams, collect?: string): void; - /** CSI X */ eraseChars(params: IParams): void; - /** CSI Z */ cursorBackwardTab(params: IParams): void; - /** CSI ` */ charPosAbsolute(params: IParams): void; - /** CSI a */ hPositionRelative(params: IParams): void; - /** CSI b */ repeatPrecedingCharacter(params: IParams): void; - /** CSI c */ sendDeviceAttributesPrimary(params: IParams): void; - /** CSI > c */ sendDeviceAttributesSecondary(params: IParams): void; - /** CSI d */ linePosAbsolute(params: IParams): void; - /** CSI e */ vPositionRelative(params: IParams): void; - /** CSI f */ hVPosition(params: IParams): void; - /** CSI g */ tabClear(params: IParams): void; - /** CSI h */ setMode(params: IParams, collect?: string): void; - /** CSI l */ resetMode(params: IParams, collect?: string): void; - /** CSI m */ charAttributes(params: IParams): void; - /** CSI n */ deviceStatus(params: IParams, collect?: string): void; - /** CSI p */ softReset(params: IParams, collect?: string): void; - /** CSI q */ setCursorStyle(params: IParams, collect?: string): void; - /** CSI r */ setScrollRegion(params: IParams, collect?: string): void; - /** CSI s */ saveCursor(params: IParams): void; - /** CSI u */ restoreCursor(params: IParams): void; - /** CSI ' } */ insertColumns(params: IParams): void; - /** CSI ' ~ */ deleteColumns(params: IParams): void; /** OSC 0 - OSC 2 */ setTitle(data: string): void; - /** OSC 4 */ setAnsiColor(data: string): void; - /** ESC E */ nextLine(): void; - /** ESC = */ keypadApplicationMode(): void; - /** ESC > */ keypadNumericMode(): void; + OSC 2 */ setTitle(data: string): boolean; + /** OSC 4 */ setOrReportIndexedColor(data: string): boolean; + /** OSC 10 */ setOrReportFgColor(data: string): boolean; + /** OSC 11 */ setOrReportBgColor(data: string): boolean; + /** OSC 12 */ setOrReportCursorColor(data: string): boolean; + /** OSC 104 */ restoreIndexedColor(data: string): boolean; + /** OSC 110 */ restoreFgColor(data: string): boolean; + /** OSC 111 */ restoreBgColor(data: string): boolean; + /** OSC 112 */ restoreCursorColor(data: string): boolean; + + /** ESC E */ nextLine(): boolean; + /** ESC = */ keypadApplicationMode(): boolean; + /** ESC > */ keypadNumericMode(): boolean; /** ESC % G - ESC % @ */ selectDefaultCharset(): void; + ESC % @ */ selectDefaultCharset(): boolean; /** ESC ( C ESC ) C ESC * C ESC + C ESC - C ESC . C - ESC / C */ selectCharset(collectAndFlag: string): void; - /** ESC D */ index(): void; - /** ESC H */ tabSet(): void; - /** ESC M */ reverseIndex(): void; - /** ESC c */ fullReset(): void; + ESC / C */ selectCharset(collectAndFlag: string): boolean; + /** ESC D */ index(): boolean; + /** ESC H */ tabSet(): boolean; + /** ESC M */ reverseIndex(): boolean; + /** ESC c */ fullReset(): boolean; /** ESC n ESC o ESC | ESC } - ESC ~ */ setgLevel(level: number): void; - /** ESC # 8 */ screenAlignmentPattern(): void; + ESC ~ */ setgLevel(level: number): boolean; + /** ESC # 8 */ screenAlignmentPattern(): boolean; } interface IParseStack { 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..8c023a38 --- /dev/null +++ b/src/common/input/XParseColor.ts @@ -0,0 +1,80 @@ +/** + * 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; + } + } + + // Named colors are currently not supported due to the large addition to the xterm.js bundle size + // they would add. In order to support named colors, we would need some way of optionally loading + // additional payloads so startup/download time is not bloated (see #3530). +} + +// 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)}`; +} diff --git a/test/api/InputHandler.api.ts b/test/api/InputHandler.api.ts index 54ee6957..4d3a15dd 100644 --- a/test/api/InputHandler.api.ts +++ b/test/api/InputHandler.api.ts @@ -4,7 +4,7 @@ */ import { assert } from 'chai'; -import { pollFor, openTerminal, getBrowserType, launchBrowser } from './TestUtils'; +import { pollFor, openTerminal, getBrowserType, launchBrowser, writeSync } from './TestUtils'; import { Browser, Page } from 'playwright'; import { IRenderDimensions } from 'browser/renderer/Types'; @@ -386,6 +386,171 @@ describe('InputHandler Integration Tests', function(): void { }); }); + describe('OSC', () => { + describe('OSC 4', () => { + before(async () => { + await page.evaluate('(() => {window._recordedData = []; window._h = term.onData(d => window._recordedData.push(d));})()'); + }); + after(async () => { + await page.evaluate('window._h.dispose()'); + }); + beforeEach(async () => { + await page.evaluate('window._recordedData.length = 0;'); + }); + it('query single color', async () => { + await writeSync(page, '\x1b]4;0;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07']); + await writeSync(page, '\x1b]4;77;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x07']); + }); + it('query multiple colors', async () => { + await writeSync(page, '\x1b]4;0;?;77;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x07']); + }); + it('set & query single color', async () => { + await writeSync(page, '\x1b]4;0;?\x07'); + const restore: string[] = await page.evaluate('window._recordedData'); + assert.deepEqual(await page.evaluate('window._recordedData'), restore); + // set new color & query + await writeSync(page, '\x1b]4;0;rgb:01/02/03\x07\x1b]4;0;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x07']); + // restore should set old color + await writeSync(page, restore[0] + '\x1b]4;0;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x07', restore[0]]); + }); + it('query & set colors mixed', async () => { + await writeSync(page, '\x1b]4;0;?;77;?\x07'); + const restore: string[] = await page.evaluate('window._recordedData'); + await page.evaluate('window._recordedData.length = 0;'); + // mixed call - change 0, query 43, change 77 + await writeSync(page, '\x1b]4;0;rgb:01/02/03;43;?;77;#aabbcc\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;43;rgb:0000/d7d7/afaf\x07']); + await page.evaluate('window._recordedData.length = 0;'); + // query new values for 0 + 77 + await writeSync(page, '\x1b]4;0;?;77;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:0101/0202/0303\x07', '\x1b]4;77;rgb:aaaa/bbbb/cccc\x07']); + await page.evaluate('window._recordedData.length = 0;'); + // restore old values for 0 + 77 + await writeSync(page, restore[0] + restore[1] + '\x1b]4;0;?;77;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), restore); + }); + }); + describe('OSC 4 & 104', () => { + before(async () => { + await page.evaluate('(() => {window._recordedData = []; window._h = term.onData(d => window._recordedData.push(d));})()'); + }); + after(async () => { + await page.evaluate('window._h.dispose()'); + }); + beforeEach(async () => { + await page.evaluate('window._recordedData.length = 0;'); + }); + it('change & restore single color', async () => { + // test for some random color slots + for (const i of [0, 43, 77, 255]) { + await writeSync(page, `\x1b]4;${i};?\x07`); + const restore: string[] = await page.evaluate('window._recordedData'); + await writeSync(page, `\x1b]4;${i};rgb:01/02/03\x07\x1b]4;${i};?\x07`); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x07`]); + // restore slot color + await writeSync(page, `\x1b]104;${i}\x07\x1b]4;${i};?\x07`); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x07`, restore[0]]); + await page.evaluate('window._recordedData.length = 0;'); + } + }); + it('restore multiple at once', async () => { + // change 3 random slots + await writeSync(page, `\x1b]4;0;?;43;?;77;?\x07`); + const restore: string[] = await page.evaluate('window._recordedData'); + await page.evaluate('window._recordedData.length = 0;'); + await writeSync(page, `\x1b]4;0;rgb:01/02/03;43;#aabbcc;77;#123456\x07`); + // restore specific slots + await writeSync(page, `\x1b]104;0;43;77\x07` + `\x1b]4;0;?;43;?;77;?\x07`); + assert.deepEqual(await page.evaluate('window._recordedData'), restore); + }); + it('restore full table', async () => { + // change 3 random slots + await writeSync(page, `\x1b]4;0;?;43;?;77;?\x07`); + const restore: string[] = await page.evaluate('window._recordedData'); + await page.evaluate('window._recordedData.length = 0;'); + await writeSync(page, `\x1b]4;0;rgb:01/02/03;43;#aabbcc;77;#123456\x07`); + // restore all + await writeSync(page, `\x1b]104\x07` + `\x1b]4;0;?;43;?;77;?\x07`); + assert.deepEqual(await page.evaluate('window._recordedData'), restore); + }); + }); + describe('OSC 10 & 11 + 110 | 111 | 112', () => { + before(async () => { + await page.evaluate('(() => {window._recordedData = []; window._h = term.onData(d => window._recordedData.push(d));})()'); + }); + after(async () => { + await page.evaluate('window._h.dispose()'); + }); + beforeEach(async () => { + await page.evaluate('window._recordedData.length = 0;'); + }); + it('query FG color', async () => { + await writeSync(page, '\x1b]10;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07']); + }); + it('query BG color', async () => { + await writeSync(page, '\x1b]11;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x07']); + }); + it('query FG & BG color in one call', async () => { + await writeSync(page, '\x1b]10;?;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07', '\x1b]11;rgb:0000/0000/0000\x07']); + }); + it('set & query FG', async () => { + await writeSync(page, '\x1b]10;rgb:1/2/3\x07\x1b]10;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07']); + await writeSync(page, '\x1b]10;#ffffff\x07\x1b]10;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07', '\x1b]10;rgb:ffff/ffff/ffff\x07']); + }); + it('set & query BG', async () => { + await writeSync(page, '\x1b]11;rgb:1/2/3\x07\x1b]11;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07']); + await writeSync(page, '\x1b]11;#000000\x07\x1b]11;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07', '\x1b]11;rgb:0000/0000/0000\x07']); + }); + it('set & query cursor color', async () => { + await writeSync(page, '\x1b]12;rgb:1/2/3\x07\x1b]12;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07']); + await writeSync(page, '\x1b]12;#ffffff\x07\x1b]12;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07', '\x1b]12;rgb:ffff/ffff/ffff\x07']); + }); + it('set & query FG & BG color in one call', async () => { + await writeSync(page, '\x1b]10;#123456;rgb:aa/bb/cc\x07\x1b]10;?;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1212/3434/5656\x07', '\x1b]11;rgb:aaaa/bbbb/cccc\x07']); + await writeSync(page, '\x1b]10;#ffffff;#000000\x07'); + }); + it('OSC 110: restore FG color', async () => { + await writeSync(page, '\x1b]10;rgb:1/2/3\x07\x1b]10;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07']); + await page.evaluate('window._recordedData.length = 0;'); + // restore + await writeSync(page, '\x1b]110\x07\x1b]10;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07']); + }); + it('OSC 111: restore BG color', async () => { + await writeSync(page, '\x1b]11;rgb:1/2/3\x07\x1b]11;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07']); + await page.evaluate('window._recordedData.length = 0;'); + // restore + await writeSync(page, '\x1b]111\x07\x1b]11;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x07']); + }); + it('OSC 112: restore cursor color', async () => { + await writeSync(page, '\x1b]12;rgb:1/2/3\x07\x1b]12;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07']); + await page.evaluate('window._recordedData.length = 0;'); + // restore + await writeSync(page, '\x1b]112\x07\x1b]12;?\x07'); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:ffff/ffff/ffff\x07']); + }); + }); + }); + describe('ESC', () => { describe('DECRC: Save cursor, ESC 7', () => { it('should save the absolute cursor position so resizing restores to the correct position', async () => {