diff --git a/addons/addon-serialize/src/SerializeAddon.test.ts b/addons/addon-serialize/src/SerializeAddon.test.ts index 6e8c2ad457..4f4fd54616 100644 --- a/addons/addon-serialize/src/SerializeAddon.test.ts +++ b/addons/addon-serialize/src/SerializeAddon.test.ts @@ -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 () => { diff --git a/addons/addon-serialize/src/SerializeAddon.ts b/addons/addon-serialize/src/SerializeAddon.ts index fadeb5bb2b..4980c8eda3 100644 --- a/addons/addon-serialize/src/SerializeAddon.ts +++ b/addons/addon-serialize/src/SerializeAddon.ts @@ -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(); @@ -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); diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index cf00b39165..ee5355fa65 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -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 { 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'; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 1f276c78f7..0cff9c25fd 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -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 diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 95fa88cd83..a5d1d759ba 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -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) { diff --git a/src/common/buffer/CellData.test.ts b/src/common/buffer/CellData.test.ts new file mode 100644 index 0000000000..77d1ef9ed0 --- /dev/null +++ b/src/common/buffer/CellData.test.ts @@ -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); + }); + }); +}); diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 9454c553cf..43c4c594b0 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -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. @@ -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; + } + } diff --git a/src/common/public/BufferLineApiView.ts b/src/common/public/BufferLineApiView.ts index 560dd0bfd9..0a747fa418 100644 --- a/src/common/public/BufferLineApiView.ts +++ b/src/common/public/BufferLineApiView.ts @@ -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); diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 9cd46c07e1..de8d06c422 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -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; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a850c7be37..c8e08f509f 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -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. */ + 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; } /**