Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addons/addon-serialize/src/SerializeAddon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
56 changes: 40 additions & 16 deletions addons/addon-serialize/src/SerializeAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions addons/addon-serialize/test/SerializeAddon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/browser/renderer/dom/DomRendererRowFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/browser/services/SelectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
65 changes: 65 additions & 0 deletions src/common/buffer/CellData.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
57 changes: 57 additions & 0 deletions src/common/buffer/CellData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CharData, 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.
Expand Down Expand Up @@ -91,4 +92,60 @@ 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()) {
if (this.getUnderlineStyle() !== other.getUnderlineStyle()) {
return false;
}
const thisDefault = this.isUnderlineColorDefault();
const otherDefault = other.isUnderlineColorDefault();
if (!(thisDefault && otherDefault)) {
if (thisDefault !== otherDefault) {
return false;
}
if (this.getUnderlineColor() !== other.getUnderlineColor()) {
return false;
}
if (this.getUnderlineColorMode() !== other.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;
}

}
4 changes: 2 additions & 2 deletions src/common/public/BufferLineApiView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions typings/xterm-headless.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
getUnderlineStyle(): number;
/** Gets the underline color number. */
getUnderlineColor(): number;
/** Gets the underline color mode. */
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;
}

/**
Expand Down
Loading
Loading