Skip to content

Commit a2bec2a

Browse files
authored
Merge pull request #5672 from Tyriar/blink_opt
Prevent blink interval from running unless needed
2 parents c102b4a + 205cf60 commit a2bec2a

File tree

7 files changed

+218
-11
lines changed

7 files changed

+218
-11
lines changed

addons/addon-webgl/src/WebglRenderer.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
3939
private _observerDisposable = this._register(new MutableDisposable());
4040

4141
private _model: RenderModel = new RenderModel();
42+
private _rowHasBlinkingCells: boolean[] = [];
43+
private _rowHasBlinkingCellsCount: number = 0;
4244
private _workCell: ICellData = new CellData();
43-
private _workCell2: ICellData = new CellData();
4445
private _cellColorResolver: CellColorResolver;
4546

4647
private _canvas: HTMLCanvasElement;
@@ -112,6 +113,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
112113
this._coreBrowserService,
113114
this._optionsService
114115
));
116+
this._resetBlinkingRowState();
115117

116118
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
117119

@@ -185,6 +187,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
185187
this._updateDimensions();
186188

187189
this._model.resize(this._terminal.cols, this._terminal.rows);
190+
this._resetBlinkingRowState();
188191

189192
// Resize all render layers
190193
for (const l of this._renderLayers) {
@@ -238,6 +241,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
238241
this._requestRedrawViewport();
239242
}
240243

244+
public handleViewportVisibilityChange(isVisible: boolean): void {
245+
this._textBlinkStateManager.setViewportVisible(isVisible);
246+
}
247+
241248
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
242249
for (const l of this._renderLayers) {
243250
l.handleSelectionChanged(this._terminal, start, end, columnSelectMode);
@@ -330,6 +337,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
330337
l.reset(this._terminal);
331338
}
332339

340+
this._resetBlinkingRowState();
341+
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
342+
333343
this._cursorBlinkStateManager.value?.restartBlinkAnimation();
334344
this._updateCursorBlink();
335345
}
@@ -427,6 +437,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
427437
for (y = start; y <= end; y++) {
428438
row = y + terminal.buffer.ydisp;
429439
line = terminal.buffer.lines.get(row)!;
440+
let rowHasBlinkingCells = false;
430441
this._model.lineLengths[y] = 0;
431442
isCursorRow = cursorY === row;
432443
skipJoinedCheckUntilX = 0;
@@ -484,6 +495,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
484495
code = cell.getCode();
485496
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
486497

498+
if (!rowHasBlinkingCells && cell.isBlink()) {
499+
rowHasBlinkingCells = true;
500+
}
501+
487502
// Load colors/resolve overrides into work colors
488503
this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width);
489504

@@ -563,11 +578,31 @@ export class WebglRenderer extends Disposable implements IRenderer {
563578
x--; // Go back to the previous update cell for next iteration
564579
}
565580
}
581+
this._setRowBlinkState(y, rowHasBlinkingCells);
566582
}
567583
if (modelUpdated) {
568584
this._rectangleRenderer.value!.updateBackgrounds(this._model);
569585
}
570586
this._rectangleRenderer.value!.updateCursor(this._model);
587+
this._updateTextBlinkState();
588+
}
589+
590+
private _resetBlinkingRowState(): void {
591+
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
592+
this._rowHasBlinkingCellsCount = 0;
593+
}
594+
595+
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
596+
const previous = this._rowHasBlinkingCells[row];
597+
if (previous === hasBlinkingCells) {
598+
return;
599+
}
600+
this._rowHasBlinkingCells[row] = hasBlinkingCells;
601+
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
602+
}
603+
604+
private _updateTextBlinkState(): void {
605+
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
571606
}
572607

573608
/**

src/browser/renderer/dom/DomRenderer.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export class DomRenderer extends Disposable implements IRenderer {
4747
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
4848
private _cursorBlinkStateManager: CursorBlinkStateManager;
4949
private _textBlinkStateManager: TextBlinkStateManager;
50+
private _rowHasBlinkingCells: boolean[] = [];
51+
private _rowHasBlinkingCellsCount: number = 0;
5052

5153
public dimensions: IRenderDimensions;
5254

@@ -329,10 +331,14 @@ export class DomRenderer extends Disposable implements IRenderer {
329331
const row = this._document.createElement('div');
330332
this._rowContainer.appendChild(row);
331333
this._rowElements.push(row);
334+
this._rowHasBlinkingCells.push(false);
332335
}
333336
// Remove excess elements
334337
while (this._rowElements.length > rows) {
335338
this._rowContainer.removeChild(this._rowElements.pop()!);
339+
if (this._rowHasBlinkingCells.pop()) {
340+
this._rowHasBlinkingCellsCount--;
341+
}
336342
}
337343
}
338344

@@ -360,6 +366,10 @@ export class DomRenderer extends Disposable implements IRenderer {
360366
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
361367
}
362368

369+
public handleViewportVisibilityChange(isVisible: boolean): void {
370+
this._textBlinkStateManager.setViewportVisible(isVisible);
371+
}
372+
363373
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
364374
// Remove all selections
365375
this._selectionContainer.replaceChildren();
@@ -461,6 +471,11 @@ export class DomRenderer extends Disposable implements IRenderer {
461471
*/
462472
e.replaceChildren();
463473
}
474+
if (this._rowHasBlinkingCellsCount > 0) {
475+
this._rowHasBlinkingCells.fill(false);
476+
this._rowHasBlinkingCellsCount = 0;
477+
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
478+
}
464479
}
465480

466481
public renderRows(start: number, end: number): void {
@@ -470,6 +485,7 @@ export class DomRenderer extends Disposable implements IRenderer {
470485
const cursorBlink = this._coreService.decPrivateModes.cursorBlink ?? this._optionsService.rawOptions.cursorBlink;
471486
const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? this._optionsService.rawOptions.cursorStyle;
472487
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
488+
const rowInfo = { hasBlinkingCells: false };
473489

474490
for (let y = start; y <= end; y++) {
475491
const row = y + buffer.ydisp;
@@ -491,10 +507,13 @@ export class DomRenderer extends Disposable implements IRenderer {
491507
this.dimensions.css.cell.width,
492508
this._widthCache,
493509
-1,
494-
-1
510+
-1,
511+
rowInfo
495512
)
496513
);
514+
this._setRowBlinkState(y, rowInfo.hasBlinkingCells);
497515
}
516+
this._updateTextBlinkState();
498517
}
499518

500519
private get _terminalSelector(): string {
@@ -539,6 +558,7 @@ export class DomRenderer extends Disposable implements IRenderer {
539558
const cursorBlink = this._optionsService.rawOptions.cursorBlink;
540559
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
541560
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
561+
const rowInfo = { hasBlinkingCells: false };
542562

543563
// refresh rows within link range
544564
for (let i = y; i <= y2; ++i) {
@@ -561,10 +581,26 @@ export class DomRenderer extends Disposable implements IRenderer {
561581
this.dimensions.css.cell.width,
562582
this._widthCache,
563583
enabled ? (i === y ? x : 0) : -1,
564-
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
584+
enabled ? ((i === y2 ? x2 : cols) - 1) : -1,
585+
rowInfo
565586
)
566587
);
588+
this._setRowBlinkState(i, rowInfo.hasBlinkingCells);
567589
}
590+
this._updateTextBlinkState();
591+
}
592+
593+
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
594+
const previous = this._rowHasBlinkingCells[row];
595+
if (previous === hasBlinkingCells) {
596+
return;
597+
}
598+
this._rowHasBlinkingCells[row] = hasBlinkingCells;
599+
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
600+
}
601+
602+
private _updateTextBlinkState(): void {
603+
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
568604
}
569605
}
570606

src/browser/renderer/dom/DomRendererRowFactory.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,14 @@ export class DomRendererRowFactory {
7171
cellWidth: number,
7272
widthCache: WidthCache,
7373
linkStart: number,
74-
linkEnd: number
74+
linkEnd: number,
75+
rowInfo?: { hasBlinkingCells: boolean }
7576
): HTMLSpanElement[] {
7677

7778
const elements: HTMLSpanElement[] = [];
79+
if (rowInfo) {
80+
rowInfo.hasBlinkingCells = false;
81+
}
7882
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
7983
const colors = this._themeService.colors;
8084

@@ -155,6 +159,9 @@ export class DomRendererRowFactory {
155159
const isInSelection = this._isCellInSelection(x, row);
156160
const isCursorCell = isCursorRow && x === cursorX;
157161
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
162+
if (rowInfo && cell.isBlink()) {
163+
rowInfo.hasBlinkingCells = true;
164+
}
158165
const isBlinkHidden = !blinkOn && cell.isBlink();
159166
if (isBlinkHidden) {
160167
classes.push(RowCss.BLINK_HIDDEN_CLASS);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { assert } from 'chai';
7+
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
8+
import { MockOptionsService } from 'common/TestUtils.test';
9+
import type { ICoreBrowserService } from 'browser/services/Services';
10+
import { Emitter } from 'common/Event';
11+
12+
class FakeWindow {
13+
public nextId = 1;
14+
public intervals = new Map<number, () => void>();
15+
16+
public setInterval(callback: () => void, _duration: number): number {
17+
const id = this.nextId++;
18+
this.intervals.set(id, callback);
19+
return id;
20+
}
21+
22+
public clearInterval(id: number): void {
23+
this.intervals.delete(id);
24+
}
25+
}
26+
27+
function createManager(duration: number): {
28+
manager: TextBlinkStateManager;
29+
window: FakeWindow;
30+
getRenderCount: () => number;
31+
} {
32+
const fakeWindow = new FakeWindow();
33+
let renderCount = 0;
34+
const coreBrowserService: ICoreBrowserService = {
35+
serviceBrand: undefined,
36+
isFocused: true,
37+
dpr: 1,
38+
onDprChange: new Emitter<number>().event,
39+
onWindowChange: new Emitter<Window & typeof globalThis>().event,
40+
window: fakeWindow as any,
41+
mainDocument: {} as any
42+
};
43+
const optionsService = new MockOptionsService({ blinkIntervalDuration: duration });
44+
const manager = new TextBlinkStateManager(() => {
45+
renderCount++;
46+
}, coreBrowserService, optionsService);
47+
return {
48+
manager,
49+
window: fakeWindow,
50+
getRenderCount: () => renderCount
51+
};
52+
}
53+
54+
function getOnlyIntervalCallback(window: FakeWindow): () => void {
55+
const iterator = window.intervals.values();
56+
const first = iterator.next();
57+
assert.ok(!first.done);
58+
assert.ok(iterator.next().done);
59+
return first.value;
60+
}
61+
62+
describe('TextBlinkStateManager', () => {
63+
it('starts interval only when needed', () => {
64+
const { manager, window } = createManager(100);
65+
assert.equal(window.intervals.size, 0);
66+
manager.setNeedsBlinkInViewport(true);
67+
assert.equal(window.intervals.size, 1);
68+
});
69+
70+
it('stops interval and restores blink visibility when no longer needed', () => {
71+
const { manager, window, getRenderCount } = createManager(100);
72+
manager.setNeedsBlinkInViewport(true);
73+
const tick = getOnlyIntervalCallback(window);
74+
tick();
75+
const rendersAfterTick = getRenderCount();
76+
assert.equal(manager.isBlinkOn, false);
77+
manager.setNeedsBlinkInViewport(false);
78+
assert.equal(window.intervals.size, 0);
79+
assert.equal(manager.isBlinkOn, true);
80+
assert.equal(getRenderCount(), rendersAfterTick + 1);
81+
});
82+
83+
it('pauses while viewport is hidden and resumes when visible', () => {
84+
const { manager, window } = createManager(100);
85+
manager.setNeedsBlinkInViewport(true);
86+
assert.equal(window.intervals.size, 1);
87+
manager.setViewportVisible(false);
88+
assert.equal(window.intervals.size, 0);
89+
manager.setViewportVisible(true);
90+
assert.equal(window.intervals.size, 1);
91+
});
92+
93+
it('does not start interval when duration is zero', () => {
94+
const { manager, window } = createManager(0);
95+
manager.setNeedsBlinkInViewport(true);
96+
assert.equal(window.intervals.size, 0);
97+
});
98+
});

0 commit comments

Comments
 (0)