diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 4739d498..8edda780 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -397,6 +397,9 @@ export class WebglRenderer extends Disposable implements IRenderer { // Apply the selection color if needed if (this._isCellSelected(x, y)) { bgOverride = this._colors.selectionOpaque.rgba >> 8 & 0xFFFFFF; + if (this._colors.selectionForeground) { + fgOverride = this._colors.selectionForeground.rgba >> 8 & 0xFFFFFF; + } } // Apply decorations on the top layer diff --git a/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts index 0ce893df..92cfd4a9 100644 --- a/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts +++ b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts @@ -23,6 +23,7 @@ export function generateConfig(scaledCellWidth: number, scaledCellHeight: number cursorAccent: NULL_COLOR, selectionTransparent: NULL_COLOR, selectionOpaque: NULL_COLOR, + selectionForeground: NULL_COLOR, // For the static char atlas, we only use the first 16 colors, but we need all 256 for the // dynamic character atlas. ansi: colors.ansi.slice(), diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index cec1e3b1..d51990a7 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -875,6 +875,25 @@ describe('WebGL Renderer Integration Tests', async () => { }); }); + describe('selectionForeground', () => { + if (areTestsEnabled) { + before(async () => setupBrowser({ rendererType: 'dom' })); + after(async () => browser.close()); + beforeEach(async () => page.evaluate(`window.term.reset()`)); + } + + itWebgl('transparent background inverse', async () => { + const theme: ITheme = { + selectionForeground: '#ff0000' + }; + await page.evaluate(`window.term.options.theme = ${JSON.stringify(theme)};`); + const data = `\\x1b[7m█\x1b[0m`; + await writeSync(page, data); + await page.evaluate(`window.term.selectAll()`); + await pollFor(page, () => getCellColor(1, 1), [255, 0, 0, 255]); + }); + }); + describe('decoration color overrides', async () => { if (areTestsEnabled) { before(async () => setupBrowser({ rendererType: 'dom' })); diff --git a/src/browser/ColorManager.test.ts b/src/browser/ColorManager.test.ts index 926a7df3..96bc82e4 100644 --- a/src/browser/ColorManager.test.ts +++ b/src/browser/ColorManager.test.ts @@ -34,7 +34,7 @@ describe('ColorManager', () => { describe('constructor', () => { it('should fill all colors with values', () => { for (const key of Object.keys(cm.colors)) { - if (key !== 'ansi' && key !== 'contrastCache') { + if (key !== 'ansi' && key !== 'contrastCache' && key !== 'selectionForeground') { // A #rrggbb or rgba(...) assert.ok((cm.colors as any)[key].css.length >= 7); } diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index 2d6e4ea5..f11ef8fd 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -104,6 +104,7 @@ export class ColorManager implements IColorManager { cursorAccent: DEFAULT_CURSOR_ACCENT, selectionTransparent: DEFAULT_SELECTION, selectionOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), + selectionForeground: undefined, ansi: DEFAULT_ANSI_COLORS.slice(), contrastCache: this._contrastCache }; @@ -128,6 +129,15 @@ export class ColorManager implements IColorManager { this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT, true); this.colors.selectionTransparent = this._parseColor(theme.selection, DEFAULT_SELECTION, true); this.colors.selectionOpaque = color.blend(this.colors.background, this.colors.selectionTransparent); + const nullColor: IColor = { + css: '', + rgba: 0 + }; + this.colors.selectionForeground = theme.selectionForeground ? this._parseColor(theme.selectionForeground, nullColor) : undefined; + if (this.colors.selectionForeground === nullColor) { + this.colors.selectionForeground = undefined; + } + /** * If selection color is opaque, blend it with background with 0.3 opacity * Issue #2737 diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 573720fc..1ba175f3 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -16,6 +16,7 @@ import { Terminal } from 'browser/Terminal'; import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService } from 'common/services/Services'; import { IFunctionIdentifier, IParams } from 'common/parser/Types'; import { AttributeData } from 'common/buffer/AttributeData'; +import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; export class TestTerminal extends Terminal { public get curAttrData(): IAttributeData { return (this as any)._inputHandler._curAttrData; } @@ -450,3 +451,54 @@ export class MockCharacterJoinerService implements ICharacterJoinerService { return []; } } + +export class MockSelectionService implements ISelectionService { + public serviceBrand: undefined; + public selectionText: string = ''; + public hasSelection: boolean = false; + public selectionStart: [number, number] | undefined; + public selectionEnd: [number, number] | undefined; + public onLinuxMouseSelection = new EventEmitter().event; + public onRequestRedraw = new EventEmitter().event; + public onRequestScrollLines = new EventEmitter().event; + public onSelectionChange = new EventEmitter().event; + public disable(): void { + throw new Error('Method not implemented.'); + } + public enable(): void { + throw new Error('Method not implemented.'); + } + public reset(): void { + throw new Error('Method not implemented.'); + } + public setSelection(row: number, col: number, length: number): void { + throw new Error('Method not implemented.'); + } + public selectAll(): void { + throw new Error('Method not implemented.'); + } + public selectLines(start: number, end: number): void { + throw new Error('Method not implemented.'); + } + public clearSelection(): void { + throw new Error('Method not implemented.'); + } + public rightClickSelect(event: MouseEvent): void { + throw new Error('Method not implemented.'); + } + public shouldColumnSelect(event: MouseEvent | KeyboardEvent): boolean { + throw new Error('Method not implemented.'); + } + public shouldForceSelection(event: MouseEvent): boolean { + throw new Error('Method not implemented.'); + } + public refresh(isLinuxMouseSelection?: boolean): void { + throw new Error('Method not implemented.'); + } + public onMouseDown(event: MouseEvent): void { + throw new Error('Method not implemented.'); + } + public isCellInSelection(x: number, y: number): boolean { + return false; + } +} diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index f8ca03df..4e203ab7 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -121,6 +121,7 @@ export interface IColorSet { selectionTransparent: IColor; /** The selection blended on top of background. */ selectionOpaque: IColor; + selectionForeground: IColor | undefined; ansi: IColor[]; contrastCache: IColorContrastCache; } diff --git a/src/browser/renderer/BaseRenderLayer.ts b/src/browser/renderer/BaseRenderLayer.ts index 696b793f..ead0844f 100644 --- a/src/browser/renderer/BaseRenderLayer.ts +++ b/src/browser/renderer/BaseRenderLayer.ts @@ -29,6 +29,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { private _scaledCharLeft: number = 0; private _scaledCharTop: number = 0; + private _selectionStart: [number, number] | undefined; + private _selectionEnd: [number, number] | undefined; + private _columnSelectMode: boolean = false; + protected _charAtlas: BaseCharAtlas | undefined; /** @@ -80,7 +84,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { public onFocus(): void {} public onCursorMove(): void {} public onGridChanged(startRow: number, endRow: number): void {} - public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {} + + public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void { + this._selectionStart = start; + this._selectionEnd = end; + this._columnSelectMode = columnSelectMode; + } public setColors(colorSet: IColorSet): void { this._refreshCharAtlas(colorSet); @@ -457,6 +466,13 @@ export abstract class BaseRenderLayer implements IRenderLayer { isTop = d.options.layer === 'top'; } + // Apply selection foreground if applicable + if (!isTop) { + if (this._colors.selectionForeground && this._isCellInSelection(x, y)) { + fgOverride = this._colors.selectionForeground.rgba; + } + } + if (!bgOverride && !fgOverride && (this._optionsService.rawOptions.minimumContrastRatio === 1 || isPowerlineGlyph(cell.getCode()))) { return undefined; } @@ -546,5 +562,21 @@ export abstract class BaseRenderLayer implements IRenderLayer { return this._colors.foreground.rgba; } } + + private _isCellInSelection(x: number, y: number): boolean { + const start = this._selectionStart; + const end = this._selectionEnd; + if (!start || !end) { + return false; + } + if (this._columnSelectMode) { + return x >= start[0] && y >= start[1] && + x < end[0] && y < end[1]; + } + return (y > start[1] && y < end[1]) || + (start[1] === end[1] && y === start[1] && x >= start[0] && x < end[0]) || + (start[1] < end[1] && y === end[1] && x < end[0]) || + (start[1] < end[1] && y === start[1] && x >= start[0]); + } } diff --git a/src/browser/renderer/Renderer.ts b/src/browser/renderer/Renderer.ts index 8dfe09c9..8bc32278 100644 --- a/src/browser/renderer/Renderer.ts +++ b/src/browser/renderer/Renderer.ts @@ -119,6 +119,10 @@ export class Renderer extends Disposable implements IRenderer { public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void { this._runOperation(l => l.onSelectionChanged(start, end, columnSelectMode)); + // Selection foreground requires a full re-render + if (this._colors.selectionForeground) { + this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 }); + } } public onCursorMove(): void { diff --git a/src/browser/renderer/SelectionRenderLayer.ts b/src/browser/renderer/SelectionRenderLayer.ts index be911eb9..ce4fe071 100644 --- a/src/browser/renderer/SelectionRenderLayer.ts +++ b/src/browser/renderer/SelectionRenderLayer.ts @@ -54,6 +54,8 @@ export class SelectionRenderLayer extends BaseRenderLayer { } public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { + super.onSelectionChanged(start, end, columnSelectMode); + // Selection has not changed if (!this._didStateChange(start, end, columnSelectMode, this._bufferService.buffer.ydisp)) { return; diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index d15d7eac..fdeef91c 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -281,6 +281,9 @@ export class DomRenderer extends Disposable implements IRenderer { this._selectionContainer.removeChild(this._selectionContainer.children[0]); } + this._rowFactory.onSelectionChanged(start, end, columnSelectMode); + this.renderRows(0, this._bufferService.rows - 1); + // Selection does not exist if (!start || !end) { return; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index bb511a47..5a374b14 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -12,7 +12,7 @@ import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test'; import { css } from 'common/Color'; -import { MockCharacterJoinerService } from 'browser/TestUtils.test'; +import { MockCharacterJoinerService, MockSelectionService } from 'browser/TestUtils.test'; describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 4bed48e5..266d7587 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -7,10 +7,10 @@ import { IBufferLine, ICellData, IColor } from 'common/Types'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; -import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { color, rgba } from 'common/Color'; import { IColorSet } from 'browser/Types'; -import { ICharacterJoinerService } from 'browser/services/Services'; +import { ICharacterJoinerService, ISelectionService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { isPowerlineGlyph } from 'browser/renderer/RendererUtils'; @@ -28,6 +28,10 @@ export const CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline'; export class DomRendererRowFactory { private _workCell: CellData = new CellData(); + private _selectionStart: [number, number] | undefined; + private _selectionEnd: [number, number] | undefined; + private _columnSelectMode: boolean = false; + constructor( private readonly _document: Document, private _colors: IColorSet, @@ -42,6 +46,12 @@ export class DomRendererRowFactory { this._colors = colors; } + public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { + this._selectionStart = start; + this._selectionEnd = end; + this._columnSelectMode = columnSelectMode; + } + public createRow(lineData: IBufferLine, row: number, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cursorBlink: boolean, cellWidth: number, cols: number): DocumentFragment { const fragment = this._document.createDocumentFragment(); @@ -195,6 +205,15 @@ export class DomRendererRowFactory { isTop = d.options.layer === 'top'; } + // Apply selection foreground if applicable + if (!isTop) { + if (this._colors.selectionForeground && this._isCellInSelection(x, row)) { + fgColorMode = Attributes.CM_RGB; + fg = this._colors.selectionForeground.rgba >> 8 & 0xFFFFFF; + fgOverride = this._colors.selectionForeground; + } + } + // If it's a top decoration, render above the selection if (isTop) { charElement.classList.add(`xterm-decoration-top`); @@ -283,6 +302,22 @@ export class DomRendererRowFactory { private _addStyle(element: HTMLElement, style: string): void { element.setAttribute('style', `${element.getAttribute('style') || ''}${style};`); } + + private _isCellInSelection(x: number, y: number): boolean { + const start = this._selectionStart; + const end = this._selectionEnd; + if (!start || !end) { + return false; + } + if (this._columnSelectMode) { + return x >= start[0] && y >= start[1] && + x < end[0] && y < end[1]; + } + return (y > start[1] && y < end[1]) || + (start[1] === end[1] && y === start[1] && x >= start[0] && x < end[0]) || + (start[1] < end[1] && y === end[1] && x < end[0]) || + (start[1] < end[1] && y === start[1] && x >= start[0]); + } } function padStart(text: string, padChar: string, length: number): string { diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 4b882e6d..c7b7707a 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -308,6 +308,15 @@ export class SelectionService extends Disposable implements ISelectionService { return this._areCoordsInSelection(coords, start, end); } + public isCellInSelection(x: number, y: number): boolean { + const start = this._model.finalSelectionStart; + const end = this._model.finalSelectionEnd; + if (!start || !end) { + return false; + } + return this._areCoordsInSelection([x, y], start, end); + } + protected _areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean { return (coords[1] > start[1] && coords[1] < end[1]) || (start[1] === end[1] && coords[1] === start[1] && coords[0] >= start[0] && coords[0] < end[0]) || diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 00534707..e1fb5dbd 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -102,6 +102,7 @@ export interface ISelectionService { shouldForceSelection(event: MouseEvent): boolean; refresh(isLinuxMouseSelection?: boolean): void; onMouseDown(event: MouseEvent): void; + isCellInSelection(x: number, y: number): boolean; } export const ISoundService = createDecorator('SoundService'); diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index c3190210..017eb386 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -259,6 +259,7 @@ export interface ITheme { cursor?: string; cursorAccent?: string; selection?: string; + selectionForeground?: string; black?: string; red?: string; green?: string; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 4ebd0254..39dcaee9 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -288,6 +288,8 @@ declare module 'xterm' { cursorAccent?: string; /** The selection background color (can be transparent) */ selection?: string; + /** The selection foreground color */ + selectionForeground?: string; /** ANSI black (eg. `\x1b[30m`) */ black?: string; /** ANSI red (eg. `\x1b[31m`) */