diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 38ad467a5..86f379b76 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -35,6 +35,7 @@ import { IRenderer } from 'browser/renderer/shared/Types'; import { CharSizeService } from 'browser/services/CharSizeService'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; +import { CellColorOverrideStore, ICellColorSpan } from 'browser/services/CellColorOverrideStore'; import { LinkProviderService } from 'browser/services/LinkProviderService'; import { MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; @@ -953,6 +954,29 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { return this._decorationService.registerDecoration(decorationOptions); } + public readonly cellColorOverrides: CellColorOverrideStore = new CellColorOverrideStore(); + + /** + * Recolor a contiguous cell range on one absolute buffer line. Pass null to + * clear the line. The affected viewport row is re-rendered lazily. Overrides + * are keyed by the active buffer's absolute line index; the caller must + * clear/re-set them when indices shift (scrollback trim, resize reflow) or + * when the alternate buffer changes. + */ + public setCellColorOverrides(lineY: number, spans: ICellColorSpan[] | null): void { + if (this.cellColorOverrides.set(lineY, spans)) { + const row = lineY - this._bufferService.buffer.ydisp; + if (row >= 0 && row < this._bufferService.rows) { + this._renderService?.refreshRows(row, row); + } + } + } + + public clearCellColorOverrides(): void { + this.cellColorOverrides.clear(); + this._renderService?.refreshRows(0, this._bufferService.rows - 1); + } + /** * Gets whether the terminal has an active selection. */ diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index cf878cbc9..ec97fbe71 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -3,7 +3,8 @@ * @license MIT */ -import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from '@xterm/xterm'; +import { CellColorOverrideStore } from 'browser/services/CellColorOverrideStore'; +import { ICellColorSpan, IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from '@xterm/xterm'; import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; import { IColorSet, ITerminal, ILinkifier2, IBrowser, IViewport, ICompositionHelper, CharacterJoinerHandler, IBufferRange, ReadonlyColorSet, IBufferElementProvider } from 'browser/Types'; @@ -30,6 +31,7 @@ export class TestTerminal extends CoreBrowserTerminal { } export class MockTerminal implements ITerminal { + public readonly cellColorOverrides = new CellColorOverrideStore(); public onBlur!: Event; public onFocus!: Event; public onA11yChar!: Event; @@ -108,6 +110,12 @@ export class MockTerminal implements ITerminal { public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { throw new Error('Method not implemented.'); } + public setCellColorOverrides(lineY: number, spans: ICellColorSpan[] | null): void { + throw new Error('Not implemented'); + } + public clearCellColorOverrides(): void { + throw new Error('Not implemented'); + } public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { throw new Error('Method not implemented.'); } diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 10e604114..69a4ccaeb 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -8,6 +8,7 @@ import { IBuffer } from 'common/buffer/Types'; import { IDisposable, Terminal as ITerminalApi } from '@xterm/xterm'; import { channels, css } from 'common/Color'; import type { Event } from 'vs/base/common/event'; +import { CellColorOverrideStore } from 'browser/services/CellColorOverrideStore'; /** * A portion of the public API that are implemented identially internally and simply passed through. @@ -20,6 +21,7 @@ export interface ITerminal extends InternalPassthroughApis, ICoreTerminal { buffer: IBuffer; linkifier: ILinkifier2 | undefined; options: Required; + cellColorOverrides: CellColorOverrideStore; onBlur: Event; onFocus: Event; diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index f6ec55d24..bba6555f6 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -12,7 +12,7 @@ import { AddonManager } from 'common/public/AddonManager'; import { BufferNamespaceApi } from 'common/public/BufferNamespaceApi'; import { ParserApi } from 'common/public/ParserApi'; import { UnicodeApi } from 'common/public/UnicodeApi'; -import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from '@xterm/xterm'; +import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling, ICellColorSpan } from '@xterm/xterm'; import type { Event } from 'vs/base/common/event'; /** @@ -177,6 +177,13 @@ export class Terminal extends Disposable implements ITerminalApi { this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0); return this._core.registerDecoration(decorationOptions); } + public setCellColorOverrides(lineY: number, spans: ICellColorSpan[] | null): void { + this._verifyIntegers(lineY); + this._core.setCellColorOverrides(lineY, spans); + } + public clearCellColorOverrides(): void { + this._core.clearCellColorOverrides(); + } public hasSelection(): boolean { return this._core.hasSelection(); } diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 1c271797a..c56b096a5 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -80,7 +80,7 @@ export class DomRenderer extends Disposable implements IRenderer { this._register(this._themeService.onChangeColors(e => this._injectCss(e))); this._injectCss(this._themeService.colors); - this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document); + this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document, this._terminal.cellColorOverrides); this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); this._screenElement.appendChild(this._rowContainer); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 14c4ade14..508be8dd7 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,6 +6,7 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory'; +import { CellColorOverrideStore } from 'browser/services/CellColorOverrideStore'; import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBufferLine } from 'common/Types'; @@ -27,6 +28,7 @@ describe('DomRendererRowFactory', () => { dom = new jsdom.JSDOM(''); rowFactory = new DomRendererRowFactory( dom.window.document, + new CellColorOverrideStore(), new MockCharacterJoinerService(), new MockOptionsService({ drawBoldTextInBrightColors: true }), new MockCoreBrowserService(), @@ -77,6 +79,7 @@ describe('DomRendererRowFactory', () => { coreBrowserService.isFocused = false; const rowFactory = new DomRendererRowFactory( dom.window.document, + new CellColorOverrideStore(), new MockCharacterJoinerService(), new MockOptionsService({ drawBoldTextInBrightColors: true }), coreBrowserService, @@ -101,6 +104,7 @@ describe('DomRendererRowFactory', () => { coreService.isCursorInitialized = false; const rowFactory = new DomRendererRowFactory( dom.window.document, + new CellColorOverrideStore(), new MockCharacterJoinerService(), new MockOptionsService(), new MockCoreBrowserService(), diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 96d3f171b..45588cf1d 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -10,6 +10,7 @@ import { CellData } from 'common/buffer/CellData'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { channels, color } from 'common/Color'; import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services'; +import { CellColorOverrideStore } from 'browser/services/CellColorOverrideStore'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; import { AttributeData } from 'common/buffer/AttributeData'; @@ -44,6 +45,7 @@ export class DomRendererRowFactory { constructor( private readonly _document: Document, + private readonly _colorOverrides: CellColorOverrideStore, @ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService, @IOptionsService private readonly _optionsService: IOptionsService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService, @@ -95,6 +97,13 @@ export class DomRendererRowFactory { let skipJoinedCheckUntilX = 0; const classes: string[] = []; + // Cell color overrides for this line (absolute row index). Looked up once + // per row, then consumed with a moving pointer since x only advances. + const colorOverrideSpans = this._colorOverrides.get(row); + let colorOverrideIdx = 0; + let colorOverride: IColor | undefined; + let oldColorOverride: IColor | undefined; + const hasHover = linkStart !== -1 && linkEnd !== -1; for (let x = 0; x < lineLength; x++) { @@ -154,6 +163,21 @@ export class DomRendererRowFactory { const isCursorCell = isCursorRow && x === cursorX; const isLinkHover = hasHover && x >= linkStart && x <= linkEnd; + // Resolve the color override covering this cell (if any). Spans are + // sorted by column and don't overlap, so a single moving pointer works. + if (colorOverrideSpans) { + while ( + colorOverrideIdx < colorOverrideSpans.length && + colorOverrideSpans[colorOverrideIdx].col + colorOverrideSpans[colorOverrideIdx].width <= x + ) { + colorOverrideIdx++; + } + const span = colorOverrideSpans[colorOverrideIdx]; + colorOverride = (span && x >= span.col) ? span.color : undefined; + } else { + colorOverride = undefined; + } + let isDecorated = false; this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { isDecorated = true; @@ -194,6 +218,7 @@ export class DomRendererRowFactory { && cell.extended.ext === oldExt && isLinkHover === oldLinkHover && spacing === oldSpacing + && colorOverride === oldColorOverride && !isCursorCell && !isJoined && !isDecorated @@ -228,6 +253,7 @@ export class DomRendererRowFactory { oldLinkHover = isLinkHover; oldSpacing = spacing; oldIsInSelection = isInSelection; + oldColorOverride = colorOverride; if (isJoined) { // The DOM renderer colors the background of the cursor but for ligatures all cells are @@ -417,7 +443,14 @@ export class DomRendererRowFactory { } // Foreground - switch (fgColorMode) { + if (colorOverride) { + // Cell color override: recolor the text directly, winning over the + // cell/decoration colors but still running through the minimum + // contrast adjustment like every other foreground source. + if (!this._applyMinimumContrast(charElement, resolvedBg, colorOverride, cell, bgOverride, colorOverride)) { + this._addStyle(charElement, `color:${colorOverride.css}`); + } + } else switch (fgColorMode) { case Attributes.CM_P16: case Attributes.CM_P256: if (cell.isBold() && fg < 8 && this._optionsService.rawOptions.drawBoldTextInBrightColors) { diff --git a/src/browser/services/CellColorOverrideStore.ts b/src/browser/services/CellColorOverrideStore.ts new file mode 100644 index 000000000..9d2c71534 --- /dev/null +++ b/src/browser/services/CellColorOverrideStore.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColor } from 'common/Types'; +import { css } from 'common/Color'; + +/** + * A single contiguous cell range on one buffer line that should render its + * text in a specific color. + */ +export interface ICellColorSpan { + /** Zero-based column of the first cell. */ + col: number; + /** Number of cells covered (wide chars count as 2). */ + width: number; + /** CSS color string (#rrggbb / rgb(...) etc.), parsed on acceptance. */ + color: string; +} + +interface StoredSpan { + col: number; + width: number; + color: IColor; +} + +/** + * Sparse per-line cell color overrides consumed by the DOM renderer's row + * factory. Keyed by absolute buffer line index of the active buffer; the + * owner is responsible for invalidating keys when the buffer shifts + * (scrollback trim, resize reflow, alternate buffer switch). + */ +export class CellColorOverrideStore { + private _overrides = new Map(); + + /** Replace the overrides of one buffer line. Returns true when the stored + * state changed and the line needs a re-render. Invalid color strings and + * zero-width spans are dropped. */ + public set(lineY: number, spans: ICellColorSpan[] | null): boolean { + if (!spans || spans.length === 0) { + return this._overrides.delete(lineY); + } + const stored: StoredSpan[] = []; + for (const span of spans) { + if (span.width <= 0) { + continue; + } + try { + stored.push({ col: span.col, width: span.width, color: css.toColor(span.color) }); + } catch { + // Unsupported color format — skip the span rather than fail the batch. + } + } + if (stored.length === 0) { + return this._overrides.delete(lineY); + } + const existing = this._overrides.get(lineY); + if ( + existing && + existing.length === stored.length && + existing.every((e, i) => + e.col === stored[i].col && + e.width === stored[i].width && + e.color.css === stored[i].color.css + ) + ) { + return false; + } + this._overrides.set(lineY, stored); + return true; + } + + public get(lineY: number): StoredSpan[] | undefined { + return this._overrides.get(lineY); + } + + public clear(): void { + this._overrides.clear(); + } +} diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts index 271b0393d..4d4e9e003 100644 --- a/test/playwright/TestUtils.ts +++ b/test/playwright/TestUtils.ts @@ -10,7 +10,7 @@ import type { IRenderService } from 'browser/services/Services'; import type { ICoreTerminal, IDisposable, IMarker } from 'common/Types'; import * as playwright from '@playwright/test'; import { PageFunction } from 'playwright-core/types/structs'; -import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm'; +import { ICellColorSpan, IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm'; export interface ITestContext { browser: Browser; @@ -279,6 +279,8 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi public async resize(cols: number, rows: number): Promise { return this._page.evaluate(([term, cols, rows]) => term.resize(cols, rows), [await this.getHandle(), cols, rows] as const); } public async registerMarker(y?: number | undefined): Promise { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); } public async registerDecoration(decorationOptions: IDecorationOptions): Promise { return this._page.evaluate(([term, decorationOptions]) => term.registerDecoration(decorationOptions), [await this.getHandle(), decorationOptions] as const); } + public setCellColorOverrides(lineY: number, spans: ICellColorSpan[] | null): void { throw new Error('not supported via proxy'); } + public async clearCellColorOverrides(): Promise { return this._page.evaluate((term) => term.clearCellColorOverrides(), await this.getHandle()); } public async clearTextureAtlas(): Promise { return this.evaluate(([term]) => term.clearTextureAtlas()); } // #endregion diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 457f1612e..a34de4cca 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -612,6 +612,19 @@ declare module '@xterm/xterm' { overviewRulerOptions?: IDecorationOverviewRulerOptions; } + /** + * A contiguous cell range on one buffer line whose text should be rendered + * in a specific color, used with {@link Terminal.setCellColorOverrides}. + */ + export interface ICellColorSpan { + /** Zero-based column of the first cell. */ + col: number; + /** Number of cells covered (wide characters count as 2 cells). */ + width: number; + /** CSS color string (#rrggbb, rgb(...), etc.). */ + color: string; + } + /** * The set of localizable strings. */ @@ -1156,6 +1169,22 @@ declare module '@xterm/xterm' { */ registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; + /** + * Recolor a contiguous range of cells on one buffer line by drawing the + * covered text in the given color. Pass null for spans to clear the line. + * Line indexes are absolute within the active buffer; the caller must + * clear/re-set overrides when indexes shift (scrollback trim, resize + * reflow) or when the alternate buffer changes. + * @param lineY The absolute buffer line index (ydisp-independent). + * @param spans The cell ranges and colors to apply, or null to clear. + */ + setCellColorOverrides(lineY: number, spans: ICellColorSpan[] | null): void; + + /** + * Clears all cell color overrides set by setCellColorOverrides. + */ + clearCellColorOverrides(): void; + /** * Gets whether the terminal has an active selection. */