Skip to content

Commit e14746e

Browse files
authored
Merge pull request #5682 from Tyriar/3440
Add attributesEqual and underline diff APIs
2 parents 5630471 + 82333b9 commit e14746e

File tree

10 files changed

+251
-22
lines changed

10 files changed

+251
-22
lines changed

addons/addon-serialize/src/SerializeAddon.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('SerializeAddon', () => {
116116
describe('underline styles', () => {
117117
it('should serialize single underline with style', async () => {
118118
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
119-
assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m');
119+
assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m');
120120
});
121121

122122
it('should serialize double underline', async () => {

addons/addon-serialize/src/SerializeAddon.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell)
8989
if (!cell1.isUnderline() && !cell2.isUnderline()) {
9090
return true;
9191
}
92-
const cell1Data = cell1 as unknown as IAttributeData;
93-
const cell2Data = cell2 as unknown as IAttributeData;
94-
return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle()
95-
&& cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor()
96-
&& cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode();
92+
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
93+
return false;
94+
}
95+
const cell1Default = cell1.isUnderlineColorDefault();
96+
const cell2Default = cell2.isUnderlineColorDefault();
97+
if (cell1Default && cell2Default) {
98+
return true;
99+
}
100+
if (cell1Default !== cell2Default) {
101+
return false;
102+
}
103+
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
104+
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
97105
}
98106

99107
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
@@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo
109117
&& cell1.isStrikethrough() === cell2.isStrikethrough();
110118
}
111119

120+
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
121+
const cell1AsBufferCell = cell1 as IBufferCell;
122+
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
123+
return cell1AsBufferCell.attributesEquals(cell2);
124+
}
125+
return equalFg(cell1, cell2)
126+
&& equalBg(cell1, cell2)
127+
&& equalFlags(cell1, cell2);
128+
}
129+
112130
class StringSerializeHandler extends BaseSerializeHandler {
113131
private _rowIndex: number = 0;
114132
private _allRows: string[] = new Array<string>();
@@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
258276

259277
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
260278
const sgrSeq: number[] = [];
279+
if (attributesEquals(cell, oldCell)) {
280+
return sgrSeq;
281+
}
261282
const fgChanged = !equalFg(cell, oldCell);
262283
const bgChanged = !equalBg(cell, oldCell);
263284
const flagsChanged = !equalFlags(cell, oldCell);
@@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler {
290311
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
291312
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
292313
if (!equalUnderline(cell, oldCell)) {
293-
const cellData = cell as unknown as IAttributeData;
294-
const style = cellData.getUnderlineStyle();
314+
const style = cell.getUnderlineStyle();
295315
if (style === UnderlineStyle.NONE) {
296316
sgrSeq.push(24);
317+
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
318+
sgrSeq.push(4);
297319
} else {
298320
// Use SGR 4:X format for underline styles
299321
sgrSeq.push('4:' + style as unknown as number);
300322
// Handle underline color
301-
if (!cellData.isUnderlineColorDefault()) {
302-
const color = cellData.getUnderlineColor();
303-
if (cellData.isUnderlineColorRGB()) {
323+
if (!cell.isUnderlineColorDefault()) {
324+
const color = cell.getUnderlineColor();
325+
if (cell.isUnderlineColorRGB()) {
304326
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
305327
} else {
306328
sgrSeq.push('58:5:' + color as unknown as number);
@@ -675,12 +697,11 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
675697
}
676698

677699
private _getUnderlineColor(cell: IBufferCell): string | undefined {
678-
const cellData = cell as unknown as IAttributeData;
679-
if (cellData.isUnderlineColorDefault()) {
700+
if (cell.isUnderlineColorDefault()) {
680701
return undefined;
681702
}
682-
const color = cellData.getUnderlineColor();
683-
if (cellData.isUnderlineColorRGB()) {
703+
const color = cell.getUnderlineColor();
704+
if (cell.isUnderlineColorRGB()) {
684705
const rgb = [
685706
(color >> 16) & 255,
686707
(color >> 8) & 255,
@@ -693,8 +714,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
693714
}
694715

695716
private _getUnderlineStyle(cell: IBufferCell): string {
696-
const cellData = cell as unknown as IAttributeData;
697-
switch (cellData.getUnderlineStyle()) {
717+
switch (cell.getUnderlineStyle()) {
698718
case UnderlineStyle.SINGLE:
699719
return 'underline';
700720
case UnderlineStyle.DOUBLE:
@@ -713,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
713733
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
714734
const content: string[] = [];
715735

736+
if (attributesEquals(cell, oldCell)) {
737+
return undefined;
738+
}
739+
716740
const fgChanged = !equalFg(cell, oldCell);
717741
const bgChanged = !equalBg(cell, oldCell);
718742
const flagsChanged = !equalFlags(cell, oldCell);

addons/addon-serialize/test/SerializeAddon.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,46 @@ test.describe('SerializeAddon', () => {
203203
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
204204
});
205205

206+
test('buffer cell attributesEquals compares underline style and color', async () => {
207+
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
208+
const sameAttributes = await ctx.page.evaluate(`(() => {
209+
const line = window.term.buffer.active.getLine(0);
210+
const cellA = line?.getCell(0);
211+
const cellB = line?.getCell(1);
212+
if (!cellA || !cellB) {
213+
return undefined;
214+
}
215+
return cellA.attributesEquals(cellB);
216+
})()`);
217+
strictEqual(sameAttributes, true);
218+
219+
await ctx.page.evaluate(`window.term.reset()`);
220+
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`);
221+
const differentColor = await ctx.page.evaluate(`(() => {
222+
const line = window.term.buffer.active.getLine(0);
223+
const cellA = line?.getCell(0);
224+
const cellB = line?.getCell(1);
225+
if (!cellA || !cellB) {
226+
return undefined;
227+
}
228+
return cellA.attributesEquals(cellB);
229+
})()`);
230+
strictEqual(differentColor, false);
231+
232+
await ctx.page.evaluate(`window.term.reset()`);
233+
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
234+
const differentStyle = await ctx.page.evaluate(`(() => {
235+
const line = window.term.buffer.active.getLine(0);
236+
const cellA = line?.getCell(0);
237+
const cellB = line?.getCell(1);
238+
if (!cellA || !cellB) {
239+
return undefined;
240+
}
241+
return cellA.attributesEquals(cellB);
242+
})()`);
243+
strictEqual(differentStyle, false);
244+
});
245+
206246
test('serialize all rows of content with color256', async function(): Promise<any> {
207247
const rows = 32;
208248
const cols = 10;
@@ -602,6 +642,9 @@ const BOLD = '1';
602642
const DIM = '2';
603643
const ITALIC = '3';
604644
const UNDERLINED = '4';
645+
const UNDERLINE_DOUBLE = '4:2';
646+
const UNDERLINE_COLOR_RED = '58;5;196';
647+
const UNDERLINE_COLOR_GREEN = '58;5;46';
605648
const BLINK = '5';
606649
const INVERSE = '7';
607650
const INVISIBLE = '8';

src/browser/renderer/dom/DomRendererRowFactory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export class DomRendererRowFactory {
124124
// Process any joined character ranges as needed. Because of how the
125125
// ranges are produced, we know that they are valid for the characters
126126
// and attributes of our input.
127-
let cell = this._workCell;
127+
let cell: ICellData = this._workCell;
128128
if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) {
129129
const range = joinedRanges.shift()!;
130130
// If the ligature's selection state is not consistent, don't join it. This helps the

src/browser/services/SelectionService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from
1111
import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
1212
import { Disposable, toDisposable } from 'common/Lifecycle';
1313
import * as Browser from 'common/Platform';
14-
import { IBufferLine, IDisposable } from 'common/Types';
14+
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
1515
import { getRangeLength } from 'common/buffer/BufferRange';
1616
import { CellData } from 'common/buffer/CellData';
1717
import { IBuffer } from 'common/buffer/Types';
@@ -1021,7 +1021,7 @@ export class SelectionService extends Disposable implements ISelectionService {
10211021
* word logic.
10221022
* @param cell The cell to check.
10231023
*/
1024-
private _isCharWordSeparator(cell: CellData): boolean {
1024+
private _isCharWordSeparator(cell: ICellData): boolean {
10251025
// Zero width characters are never separators as they are always to the
10261026
// right of wide characters
10271027
if (cell.getWidth() === 0) {

src/common/buffer/CellData.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
import { Attributes, BgFlags, FgFlags, UnderlineStyle } from 'common/buffer/Constants';
6+
import { CellData } from 'common/buffer/CellData';
7+
import { assert } from 'chai';
8+
9+
function createStyledCell(char: string, underlineStyle: UnderlineStyle, underlineColor: number): CellData {
10+
const cell = new CellData();
11+
const fg = Attributes.CM_P256 | 12 | FgFlags.BOLD | FgFlags.UNDERLINE;
12+
cell.setFromCharData([fg, char, 1, char.charCodeAt(0)]);
13+
cell.bg = Attributes.CM_P16 | 2 | BgFlags.ITALIC;
14+
cell.extended.underlineStyle = underlineStyle;
15+
cell.extended.underlineColor = Attributes.CM_P256 | underlineColor;
16+
cell.updateExtended();
17+
return cell;
18+
}
19+
20+
describe('CellData', () => {
21+
describe('attributesEquals', () => {
22+
it('returns true for same attributes with different chars', () => {
23+
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
24+
const cellB = createStyledCell('B', UnderlineStyle.DOUBLE, 45);
25+
26+
assert.equal(cellA.attributesEquals(cellB), true);
27+
});
28+
29+
it('detects underline style changes', () => {
30+
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
31+
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
32+
33+
assert.equal(cellA.attributesEquals(cellB), false);
34+
});
35+
36+
it('detects underline color changes', () => {
37+
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
38+
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 46);
39+
40+
assert.equal(cellA.attributesEquals(cellB), false);
41+
});
42+
43+
it('ignores underline variant offsets', () => {
44+
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
45+
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
46+
cellA.extended.underlineVariantOffset = 1;
47+
cellB.extended.underlineVariantOffset = 3;
48+
cellA.updateExtended();
49+
cellB.updateExtended();
50+
51+
assert.equal(cellA.attributesEquals(cellB), true);
52+
});
53+
54+
it('ignores url ids', () => {
55+
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
56+
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
57+
cellA.extended.urlId = 1;
58+
cellB.extended.urlId = 2;
59+
cellA.updateExtended();
60+
cellB.updateExtended();
61+
62+
assert.equal(cellA.attributesEquals(cellB), true);
63+
});
64+
});
65+
});

src/common/buffer/CellData.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CharData, ICellData, IExtendedAttrs } from 'common/Types';
77
import { stringFromCodePoint } from 'common/input/TextDecoder';
88
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants';
99
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
10+
import type { IBufferCell as IBufferCellApi } from '@xterm/xterm';
1011

1112
/**
1213
* CellData - represents a single Cell in the terminal buffer.
@@ -91,4 +92,60 @@ export class CellData extends AttributeData implements ICellData {
9192
public getAsCharData(): CharData {
9293
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
9394
}
95+
96+
public attributesEquals(other: IBufferCellApi): boolean {
97+
if (this.getFgColorMode() !== other.getFgColorMode() || this.getFgColor() !== other.getFgColor()) {
98+
return false;
99+
}
100+
if (this.getBgColorMode() !== other.getBgColorMode() || this.getBgColor() !== other.getBgColor()) {
101+
return false;
102+
}
103+
if (this.isInverse() !== other.isInverse()) {
104+
return false;
105+
}
106+
if (this.isBold() !== other.isBold()) {
107+
return false;
108+
}
109+
if (this.isUnderline() !== other.isUnderline()) {
110+
return false;
111+
}
112+
if (this.isUnderline()) {
113+
if (this.getUnderlineStyle() !== other.getUnderlineStyle()) {
114+
return false;
115+
}
116+
const thisDefault = this.isUnderlineColorDefault();
117+
const otherDefault = other.isUnderlineColorDefault();
118+
if (!(thisDefault && otherDefault)) {
119+
if (thisDefault !== otherDefault) {
120+
return false;
121+
}
122+
if (this.getUnderlineColor() !== other.getUnderlineColor()) {
123+
return false;
124+
}
125+
if (this.getUnderlineColorMode() !== other.getUnderlineColorMode()) {
126+
return false;
127+
}
128+
}
129+
}
130+
if (this.isOverline() !== other.isOverline()) {
131+
return false;
132+
}
133+
if (this.isBlink() !== other.isBlink()) {
134+
return false;
135+
}
136+
if (this.isInvisible() !== other.isInvisible()) {
137+
return false;
138+
}
139+
if (this.isItalic() !== other.isItalic()) {
140+
return false;
141+
}
142+
if (this.isDim() !== other.isDim()) {
143+
return false;
144+
}
145+
if (this.isStrikethrough() !== other.isStrikethrough()) {
146+
return false;
147+
}
148+
return true;
149+
}
150+
94151
}

src/common/public/BufferLineApiView.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ export class BufferLineApiView implements IBufferLineApi {
1818
}
1919

2020
if (cell) {
21-
this._line.loadCell(x, cell as ICellData);
21+
this._line.loadCell(x, cell as unknown as ICellData);
2222
return cell;
2323
}
24-
return this._line.loadCell(x, new CellData());
24+
return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi;
2525
}
2626
public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string {
2727
return this._line.translateToString(trimRight, startColumn, endColumn);

typings/xterm-headless.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,26 @@ declare module '@xterm/headless' {
12181218

12191219
/** Whether the cell has the default attribute (no color or style). */
12201220
isAttributeDefault(): boolean;
1221+
1222+
/** Gets the underline style. */
1223+
getUnderlineStyle(): number;
1224+
/** Gets the underline color number. */
1225+
getUnderlineColor(): number;
1226+
/** Gets the underline color mode. */
1227+
getUnderlineColorMode(): number;
1228+
/** Whether the cell is using the RGB underline color mode. */
1229+
isUnderlineColorRGB(): boolean;
1230+
/** Whether the cell is using the palette underline color mode. */
1231+
isUnderlineColorPalette(): boolean;
1232+
/** Whether the cell is using the default underline color mode. */
1233+
isUnderlineColorDefault(): boolean;
1234+
1235+
/**
1236+
* Compares the cell's attributes (colors and styles) with another cell.
1237+
* This does not compare the cell's content and excludes URL ids and
1238+
* underline variant offsets.
1239+
*/
1240+
attributesEquals(other: IBufferCell): boolean;
12211241
}
12221242

12231243
/**

0 commit comments

Comments
 (0)