Compare commits

...
Author SHA1 Message Date
ys-ll 714fda4575 Bump version to 6.0.0-uniterm.1 2026-09-20 19:20:47 +08:00
ys-ll d07813da37 Add cell color override API for text recoloring
Add setCellColorOverrides/clearCellColorOverrides to the terminal API,
backed by a per-line sparse override store consumed by the DOM renderer's
row factory. Covered cells render their original text in the given color,
with override equality participating in span merging so runs break only at
color boundaries. Overrides are keyed by absolute buffer line of the active
buffer; refresh is limited to the affected viewport rows.

This gives embedders a cheap way to recolor terminal text (e.g. keyword
highlighting) without decoration objects, which fragment text spans and
carry per-cell lookup cost in the DOM renderer, and which are force-hidden
while the alternate buffer is active.
2026-09-20 19:20:47 +08:00
11 changed files with 196 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@xterm/xterm",
"description": "Full xterm terminal, in your browser",
"version": "6.0.0",
"version": "6.0.0-uniterm.1",
"main": "lib/xterm.js",
"module": "lib/xterm.mjs",
"style": "css/xterm.css",
+24
View File
@@ -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.
*/
+9 -1
View File
@@ -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<void>;
public onFocus!: Event<void>;
public onA11yChar!: Event<string>;
@@ -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.');
}
+2
View File
@@ -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<ITerminalOptions>;
cellColorOverrides: CellColorOverrideStore;
onBlur: Event<void>;
onFocus: Event<void>;
+8 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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);
@@ -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(),
@@ -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) {
@@ -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<number, StoredSpan[]>();
/** 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();
}
}
+3 -1
View File
@@ -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<void> { 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<IMarker> { return this._page.evaluate(([term, y]) => term.registerMarker(y), [await this.getHandle(), y] as const); }
public async registerDecoration(decorationOptions: IDecorationOptions): Promise<IDecoration | undefined> { 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<void> { return this._page.evaluate((term) => term.clearCellColorOverrides(), await this.getHandle()); }
public async clearTextureAtlas(): Promise<void> { return this.evaluate(([term]) => term.clearTextureAtlas()); }
// #endregion
+29
View File
@@ -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.
*/