Add attributesEqual and underline diff APIs

Fixes #3440
This commit is contained in:
Daniel Imms
2026-02-06 16:21:25 -08:00
parent 452ee50c81
commit cc8e680790
10 changed files with 253 additions and 23 deletions
@@ -116,7 +116,7 @@ describe('SerializeAddon', () => {
describe('underline styles', () => {
it('should serialize single underline with style', async () => {
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m');
assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m');
});
it('should serialize double underline', async () => {
+40 -16
View File
@@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell)
if (!cell1.isUnderline() && !cell2.isUnderline()) {
return true;
}
const cell1Data = cell1 as unknown as IAttributeData;
const cell2Data = cell2 as unknown as IAttributeData;
return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle()
&& cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor()
&& cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode();
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
return false;
}
const cell1Default = cell1.isUnderlineColorDefault();
const cell2Default = cell2.isUnderlineColorDefault();
if (cell1Default && cell2Default) {
return true;
}
if (cell1Default !== cell2Default) {
return false;
}
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
}
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
@@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo
&& cell1.isStrikethrough() === cell2.isStrikethrough();
}
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
const cell1AsBufferCell = cell1 as IBufferCell;
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
return cell1AsBufferCell.attributesEquals(cell2);
}
return equalFg(cell1, cell2)
&& equalBg(cell1, cell2)
&& equalFlags(cell1, cell2);
}
class StringSerializeHandler extends BaseSerializeHandler {
private _rowIndex: number = 0;
private _allRows: string[] = new Array<string>();
@@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
const sgrSeq: number[] = [];
if (attributesEquals(cell, oldCell)) {
return sgrSeq;
}
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
const flagsChanged = !equalFlags(cell, oldCell);
@@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
if (!equalUnderline(cell, oldCell)) {
const cellData = cell as unknown as IAttributeData;
const style = cellData.getUnderlineStyle();
const style = cell.getUnderlineStyle();
if (style === UnderlineStyle.NONE) {
sgrSeq.push(24);
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
sgrSeq.push(4);
} else {
// Use SGR 4:X format for underline styles
sgrSeq.push('4:' + style as unknown as number);
// Handle underline color
if (!cellData.isUnderlineColorDefault()) {
const color = cellData.getUnderlineColor();
if (cellData.isUnderlineColorRGB()) {
if (!cell.isUnderlineColorDefault()) {
const color = cell.getUnderlineColor();
if (cell.isUnderlineColorRGB()) {
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
} else {
sgrSeq.push('58:5:' + color as unknown as number);
@@ -675,12 +697,11 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
}
private _getUnderlineColor(cell: IBufferCell): string | undefined {
const cellData = cell as unknown as IAttributeData;
if (cellData.isUnderlineColorDefault()) {
if (cell.isUnderlineColorDefault()) {
return undefined;
}
const color = cellData.getUnderlineColor();
if (cellData.isUnderlineColorRGB()) {
const color = cell.getUnderlineColor();
if (cell.isUnderlineColorRGB()) {
const rgb = [
(color >> 16) & 255,
(color >> 8) & 255,
@@ -693,8 +714,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
}
private _getUnderlineStyle(cell: IBufferCell): string {
const cellData = cell as unknown as IAttributeData;
switch (cellData.getUnderlineStyle()) {
switch (cell.getUnderlineStyle()) {
case UnderlineStyle.SINGLE:
return 'underline';
case UnderlineStyle.DOUBLE:
@@ -713,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
const content: string[] = [];
if (attributesEquals(cell, oldCell)) {
return undefined;
}
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
const flagsChanged = !equalFlags(cell, oldCell);
@@ -203,6 +203,46 @@ test.describe('SerializeAddon', () => {
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
});
test('buffer cell attributesEquals compares underline style and color', async () => {
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
const sameAttributes = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(sameAttributes, true);
await ctx.page.evaluate(`window.term.reset()`);
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`);
const differentColor = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(differentColor, false);
await ctx.page.evaluate(`window.term.reset()`);
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
const differentStyle = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(differentStyle, false);
});
test('serialize all rows of content with color256', async function(): Promise<any> {
const rows = 32;
const cols = 10;
@@ -602,6 +642,9 @@ const BOLD = '1';
const DIM = '2';
const ITALIC = '3';
const UNDERLINED = '4';
const UNDERLINE_DOUBLE = '4:2';
const UNDERLINE_COLOR_RED = '58;5;196';
const UNDERLINE_COLOR_GREEN = '58;5;46';
const BLINK = '5';
const INVERSE = '7';
const INVISIBLE = '8';
@@ -124,7 +124,7 @@ export class DomRendererRowFactory {
// Process any joined character ranges as needed. Because of how the
// ranges are produced, we know that they are valid for the characters
// and attributes of our input.
let cell = this._workCell;
let cell: ICellData = this._workCell;
if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) {
const range = joinedRanges.shift()!;
// If the ligature's selection state is not consistent, don't join it. This helps the
+2 -2
View File
@@ -11,7 +11,7 @@ import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from
import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import * as Browser from 'common/Platform';
import { IBufferLine, IDisposable } from 'common/Types';
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
import { getRangeLength } from 'common/buffer/BufferRange';
import { CellData } from 'common/buffer/CellData';
import { IBuffer } from 'common/buffer/Types';
@@ -1021,7 +1021,7 @@ export class SelectionService extends Disposable implements ISelectionService {
* word logic.
* @param cell The cell to check.
*/
private _isCharWordSeparator(cell: CellData): boolean {
private _isCharWordSeparator(cell: ICellData): boolean {
// Zero width characters are never separators as they are always to the
// right of wide characters
if (cell.getWidth() === 0) {
+65
View File
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Attributes, BgFlags, FgFlags, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { assert } from 'chai';
function createStyledCell(char: string, underlineStyle: UnderlineStyle, underlineColor: number): CellData {
const cell = new CellData();
const fg = Attributes.CM_P256 | 12 | FgFlags.BOLD | FgFlags.UNDERLINE;
cell.setFromCharData([fg, char, 1, char.charCodeAt(0)]);
cell.bg = Attributes.CM_P16 | 2 | BgFlags.ITALIC;
cell.extended.underlineStyle = underlineStyle;
cell.extended.underlineColor = Attributes.CM_P256 | underlineColor;
cell.updateExtended();
return cell;
}
describe('CellData', () => {
describe('attributesEquals', () => {
it('returns true for same attributes with different chars', () => {
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
const cellB = createStyledCell('B', UnderlineStyle.DOUBLE, 45);
assert.equal(cellA.attributesEquals(cellB), true);
});
it('detects underline style changes', () => {
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
assert.equal(cellA.attributesEquals(cellB), false);
});
it('detects underline color changes', () => {
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 46);
assert.equal(cellA.attributesEquals(cellB), false);
});
it('ignores underline variant offsets', () => {
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
cellA.extended.underlineVariantOffset = 1;
cellB.extended.underlineVariantOffset = 3;
cellA.updateExtended();
cellB.updateExtended();
assert.equal(cellA.attributesEquals(cellB), true);
});
it('ignores url ids', () => {
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
cellA.extended.urlId = 1;
cellB.extended.urlId = 2;
cellA.updateExtended();
cellB.updateExtended();
assert.equal(cellA.attributesEquals(cellB), true);
});
});
});
+59 -1
View File
@@ -3,10 +3,11 @@
* @license MIT
*/
import { CharData, ICellData, IExtendedAttrs } from 'common/Types';
import { CharData, IAttributeData, ICellData, IExtendedAttrs } from 'common/Types';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
import type { IBufferCell as IBufferCellApi } from '@xterm/xterm';
/**
* CellData - represents a single Cell in the terminal buffer.
@@ -91,4 +92,61 @@ export class CellData extends AttributeData implements ICellData {
public getAsCharData(): CharData {
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
}
public attributesEquals(other: IBufferCellApi): boolean {
if (this.getFgColorMode() !== other.getFgColorMode() || this.getFgColor() !== other.getFgColor()) {
return false;
}
if (this.getBgColorMode() !== other.getBgColorMode() || this.getBgColor() !== other.getBgColor()) {
return false;
}
if (this.isInverse() !== other.isInverse()) {
return false;
}
if (this.isBold() !== other.isBold()) {
return false;
}
if (this.isUnderline() !== other.isUnderline()) {
return false;
}
if (this.isUnderline()) {
const otherData = other as unknown as IAttributeData;
if (this.getUnderlineStyle() !== otherData.getUnderlineStyle()) {
return false;
}
const thisDefault = this.isUnderlineColorDefault();
const otherDefault = otherData.isUnderlineColorDefault();
if (!(thisDefault && otherDefault)) {
if (thisDefault !== otherDefault) {
return false;
}
if (this.getUnderlineColor() !== otherData.getUnderlineColor()) {
return false;
}
if (this.getUnderlineColorMode() !== otherData.getUnderlineColorMode()) {
return false;
}
}
}
if (this.isOverline() !== other.isOverline()) {
return false;
}
if (this.isBlink() !== other.isBlink()) {
return false;
}
if (this.isInvisible() !== other.isInvisible()) {
return false;
}
if (this.isItalic() !== other.isItalic()) {
return false;
}
if (this.isDim() !== other.isDim()) {
return false;
}
if (this.isStrikethrough() !== other.isStrikethrough()) {
return false;
}
return true;
}
}
+2 -2
View File
@@ -18,10 +18,10 @@ export class BufferLineApiView implements IBufferLineApi {
}
if (cell) {
this._line.loadCell(x, cell as ICellData);
this._line.loadCell(x, cell as unknown as ICellData);
return cell;
}
return this._line.loadCell(x, new CellData());
return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi;
}
public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string {
return this._line.translateToString(trimRight, startColumn, endColumn);
+20
View File
@@ -1218,6 +1218,26 @@ declare module '@xterm/headless' {
/** Whether the cell has the default attribute (no color or style). */
isAttributeDefault(): boolean;
/** Gets the underline style, see {@link UnderlineStyle}. */
getUnderlineStyle(): number;
/** Gets the underline color number, following the same rules as foreground colors. */
getUnderlineColor(): number;
/** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */
getUnderlineColorMode(): number;
/** Whether the cell is using the RGB underline color mode. */
isUnderlineColorRGB(): boolean;
/** Whether the cell is using the palette underline color mode. */
isUnderlineColorPalette(): boolean;
/** Whether the cell is using the default underline color mode. */
isUnderlineColorDefault(): boolean;
/**
* Compares the cell's attributes (colors and styles) with another cell.
* This does not compare the cell's content and excludes URL ids and
* underline variant offsets.
*/
attributesEquals(other: IBufferCell): boolean;
}
/**
+20
View File
@@ -1853,6 +1853,26 @@ declare module '@xterm/xterm' {
/** Whether the cell has the default attribute (no color or style). */
isAttributeDefault(): boolean;
/** Gets the underline style, see {@link UnderlineStyle}. */
getUnderlineStyle(): number;
/** Gets the underline color number, following the same rules as foreground colors. */
getUnderlineColor(): number;
/** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */
getUnderlineColorMode(): number;
/** Whether the cell is using the RGB underline color mode. */
isUnderlineColorRGB(): boolean;
/** Whether the cell is using the palette underline color mode. */
isUnderlineColorPalette(): boolean;
/** Whether the cell is using the default underline color mode. */
isUnderlineColorDefault(): boolean;
/**
* Compares the cell's attributes (colors and styles) with another cell.
* This does not compare the cell's content and excludes URL ids and
* underline variant offsets.
*/
attributesEquals(other: IBufferCell): boolean;
}
/**