Merge pull request #5672 from Tyriar/blink_opt

Prevent blink interval from running unless needed
This commit is contained in:
Daniel Imms
2026-02-03 05:15:45 -08:00
committed by GitHub
7 changed files with 218 additions and 11 deletions
+36 -1
View File
@@ -39,8 +39,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
private _observerDisposable = this._register(new MutableDisposable());
private _model: RenderModel = new RenderModel();
private _rowHasBlinkingCells: boolean[] = [];
private _rowHasBlinkingCellsCount: number = 0;
private _workCell: ICellData = new CellData();
private _workCell2: ICellData = new CellData();
private _cellColorResolver: CellColorResolver;
private _canvas: HTMLCanvasElement;
@@ -112,6 +113,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._coreBrowserService,
this._optionsService
));
this._resetBlinkingRowState();
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
@@ -185,6 +187,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateDimensions();
this._model.resize(this._terminal.cols, this._terminal.rows);
this._resetBlinkingRowState();
// Resize all render layers
for (const l of this._renderLayers) {
@@ -238,6 +241,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._requestRedrawViewport();
}
public handleViewportVisibilityChange(isVisible: boolean): void {
this._textBlinkStateManager.setViewportVisible(isVisible);
}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
for (const l of this._renderLayers) {
l.handleSelectionChanged(this._terminal, start, end, columnSelectMode);
@@ -330,6 +337,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
l.reset(this._terminal);
}
this._resetBlinkingRowState();
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
this._cursorBlinkStateManager.value?.restartBlinkAnimation();
this._updateCursorBlink();
}
@@ -427,6 +437,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
for (y = start; y <= end; y++) {
row = y + terminal.buffer.ydisp;
line = terminal.buffer.lines.get(row)!;
let rowHasBlinkingCells = false;
this._model.lineLengths[y] = 0;
isCursorRow = cursorY === row;
skipJoinedCheckUntilX = 0;
@@ -484,6 +495,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
code = cell.getCode();
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
if (!rowHasBlinkingCells && cell.isBlink()) {
rowHasBlinkingCells = true;
}
// Load colors/resolve overrides into work colors
this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width);
@@ -563,11 +578,31 @@ export class WebglRenderer extends Disposable implements IRenderer {
x--; // Go back to the previous update cell for next iteration
}
}
this._setRowBlinkState(y, rowHasBlinkingCells);
}
if (modelUpdated) {
this._rectangleRenderer.value!.updateBackgrounds(this._model);
}
this._rectangleRenderer.value!.updateCursor(this._model);
this._updateTextBlinkState();
}
private _resetBlinkingRowState(): void {
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
this._rowHasBlinkingCellsCount = 0;
}
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
const previous = this._rowHasBlinkingCells[row];
if (previous === hasBlinkingCells) {
return;
}
this._rowHasBlinkingCells[row] = hasBlinkingCells;
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
}
private _updateTextBlinkState(): void {
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
}
/**
+38 -2
View File
@@ -47,6 +47,8 @@ export class DomRenderer extends Disposable implements IRenderer {
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
private _cursorBlinkStateManager: CursorBlinkStateManager;
private _textBlinkStateManager: TextBlinkStateManager;
private _rowHasBlinkingCells: boolean[] = [];
private _rowHasBlinkingCellsCount: number = 0;
public dimensions: IRenderDimensions;
@@ -329,10 +331,14 @@ export class DomRenderer extends Disposable implements IRenderer {
const row = this._document.createElement('div');
this._rowContainer.appendChild(row);
this._rowElements.push(row);
this._rowHasBlinkingCells.push(false);
}
// Remove excess elements
while (this._rowElements.length > rows) {
this._rowContainer.removeChild(this._rowElements.pop()!);
if (this._rowHasBlinkingCells.pop()) {
this._rowHasBlinkingCellsCount--;
}
}
}
@@ -360,6 +366,10 @@ export class DomRenderer extends Disposable implements IRenderer {
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
}
public handleViewportVisibilityChange(isVisible: boolean): void {
this._textBlinkStateManager.setViewportVisible(isVisible);
}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
// Remove all selections
this._selectionContainer.replaceChildren();
@@ -461,6 +471,11 @@ export class DomRenderer extends Disposable implements IRenderer {
*/
e.replaceChildren();
}
if (this._rowHasBlinkingCellsCount > 0) {
this._rowHasBlinkingCells.fill(false);
this._rowHasBlinkingCellsCount = 0;
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
}
}
public renderRows(start: number, end: number): void {
@@ -470,6 +485,7 @@ export class DomRenderer extends Disposable implements IRenderer {
const cursorBlink = this._coreService.decPrivateModes.cursorBlink ?? this._optionsService.rawOptions.cursorBlink;
const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? this._optionsService.rawOptions.cursorStyle;
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
const rowInfo = { hasBlinkingCells: false };
for (let y = start; y <= end; y++) {
const row = y + buffer.ydisp;
@@ -491,10 +507,13 @@ export class DomRenderer extends Disposable implements IRenderer {
this.dimensions.css.cell.width,
this._widthCache,
-1,
-1
-1,
rowInfo
)
);
this._setRowBlinkState(y, rowInfo.hasBlinkingCells);
}
this._updateTextBlinkState();
}
private get _terminalSelector(): string {
@@ -539,6 +558,7 @@ export class DomRenderer extends Disposable implements IRenderer {
const cursorBlink = this._optionsService.rawOptions.cursorBlink;
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
const rowInfo = { hasBlinkingCells: false };
// refresh rows within link range
for (let i = y; i <= y2; ++i) {
@@ -561,10 +581,26 @@ export class DomRenderer extends Disposable implements IRenderer {
this.dimensions.css.cell.width,
this._widthCache,
enabled ? (i === y ? x : 0) : -1,
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
enabled ? ((i === y2 ? x2 : cols) - 1) : -1,
rowInfo
)
);
this._setRowBlinkState(i, rowInfo.hasBlinkingCells);
}
this._updateTextBlinkState();
}
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
const previous = this._rowHasBlinkingCells[row];
if (previous === hasBlinkingCells) {
return;
}
this._rowHasBlinkingCells[row] = hasBlinkingCells;
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
}
private _updateTextBlinkState(): void {
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
}
}
@@ -71,10 +71,14 @@ export class DomRendererRowFactory {
cellWidth: number,
widthCache: WidthCache,
linkStart: number,
linkEnd: number
linkEnd: number,
rowInfo?: { hasBlinkingCells: boolean }
): HTMLSpanElement[] {
const elements: HTMLSpanElement[] = [];
if (rowInfo) {
rowInfo.hasBlinkingCells = false;
}
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
const colors = this._themeService.colors;
@@ -155,6 +159,9 @@ export class DomRendererRowFactory {
const isInSelection = this._isCellInSelection(x, row);
const isCursorCell = isCursorRow && x === cursorX;
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
if (rowInfo && cell.isBlink()) {
rowInfo.hasBlinkingCells = true;
}
const isBlinkHidden = !blinkOn && cell.isBlink();
if (isBlinkHidden) {
classes.push(RowCss.BLINK_HIDDEN_CLASS);
@@ -0,0 +1,98 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
import { MockOptionsService } from 'common/TestUtils.test';
import type { ICoreBrowserService } from 'browser/services/Services';
import { Emitter } from 'common/Event';
class FakeWindow {
public nextId = 1;
public intervals = new Map<number, () => void>();
public setInterval(callback: () => void, _duration: number): number {
const id = this.nextId++;
this.intervals.set(id, callback);
return id;
}
public clearInterval(id: number): void {
this.intervals.delete(id);
}
}
function createManager(duration: number): {
manager: TextBlinkStateManager;
window: FakeWindow;
getRenderCount: () => number;
} {
const fakeWindow = new FakeWindow();
let renderCount = 0;
const coreBrowserService: ICoreBrowserService = {
serviceBrand: undefined,
isFocused: true,
dpr: 1,
onDprChange: new Emitter<number>().event,
onWindowChange: new Emitter<Window & typeof globalThis>().event,
window: fakeWindow as any,
mainDocument: {} as any
};
const optionsService = new MockOptionsService({ blinkIntervalDuration: duration });
const manager = new TextBlinkStateManager(() => {
renderCount++;
}, coreBrowserService, optionsService);
return {
manager,
window: fakeWindow,
getRenderCount: () => renderCount
};
}
function getOnlyIntervalCallback(window: FakeWindow): () => void {
const iterator = window.intervals.values();
const first = iterator.next();
assert.ok(!first.done);
assert.ok(iterator.next().done);
return first.value;
}
describe('TextBlinkStateManager', () => {
it('starts interval only when needed', () => {
const { manager, window } = createManager(100);
assert.equal(window.intervals.size, 0);
manager.setNeedsBlinkInViewport(true);
assert.equal(window.intervals.size, 1);
});
it('stops interval and restores blink visibility when no longer needed', () => {
const { manager, window, getRenderCount } = createManager(100);
manager.setNeedsBlinkInViewport(true);
const tick = getOnlyIntervalCallback(window);
tick();
const rendersAfterTick = getRenderCount();
assert.equal(manager.isBlinkOn, false);
manager.setNeedsBlinkInViewport(false);
assert.equal(window.intervals.size, 0);
assert.equal(manager.isBlinkOn, true);
assert.equal(getRenderCount(), rendersAfterTick + 1);
});
it('pauses while viewport is hidden and resumes when visible', () => {
const { manager, window } = createManager(100);
manager.setNeedsBlinkInViewport(true);
assert.equal(window.intervals.size, 1);
manager.setViewportVisible(false);
assert.equal(window.intervals.size, 0);
manager.setViewportVisible(true);
assert.equal(window.intervals.size, 1);
});
it('does not start interval when duration is zero', () => {
const { manager, window } = createManager(0);
manager.setNeedsBlinkInViewport(true);
assert.equal(window.intervals.size, 0);
});
});
@@ -11,6 +11,8 @@ export class TextBlinkStateManager extends Disposable {
private _intervalDuration: number = 0;
private _interval: number | undefined;
private _blinkOn: boolean = true;
private _needsBlinkInViewport: boolean = false;
private _isViewportVisible: boolean = true;
constructor(
private readonly _renderCallback: () => void,
@@ -33,6 +35,24 @@ export class TextBlinkStateManager extends Disposable {
return this._intervalDuration > 0;
}
public setNeedsBlinkInViewport(needsBlinkInViewport: boolean): void {
if (this._needsBlinkInViewport === needsBlinkInViewport) {
return;
}
this._needsBlinkInViewport = needsBlinkInViewport;
this._updateIntervalState();
}
public setViewportVisible(isVisible: boolean): void {
if (this._isViewportVisible === isVisible) {
return;
}
this._isViewportVisible = isVisible;
this._updateIntervalState();
}
public setIntervalDuration(duration: number): void {
if (duration === this._intervalDuration) {
return;
@@ -40,22 +60,31 @@ export class TextBlinkStateManager extends Disposable {
this._intervalDuration = duration;
this._clearInterval();
this._updateIntervalState();
}
const wasBlinkOn = this._blinkOn;
if (duration > 0) {
private _updateIntervalState(): void {
const shouldBlink = this._intervalDuration > 0 && this._needsBlinkInViewport && this._isViewportVisible;
if (shouldBlink) {
if (this._interval !== undefined) {
return;
}
const wasBlinkOn = this._blinkOn;
this._blinkOn = true;
this._interval = this._coreBrowserService.window.setInterval(() => {
this._blinkOn = !this._blinkOn;
this._renderCallback();
}, duration);
}, this._intervalDuration);
if (!wasBlinkOn) {
this._renderCallback();
}
} else {
return;
}
this._clearInterval();
if (!this._blinkOn) {
this._blinkOn = true;
if (!wasBlinkOn) {
this._renderCallback();
}
this._renderCallback();
}
}
+1
View File
@@ -65,6 +65,7 @@ export interface IRenderer extends IDisposable {
handleCharSizeChanged(): void;
handleBlur(): void;
handleFocus(): void;
handleViewportVisibilityChange?(isVisible: boolean): void;
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
handleCursorMove(): void;
clear(): void;
+1
View File
@@ -131,6 +131,7 @@ export class RenderService extends Disposable implements IRenderService {
private _handleIntersectionChange(entry: IntersectionObserverEntry): void {
this._isPaused = entry.isIntersecting === undefined ? (entry.intersectionRatio === 0) : !entry.isIntersecting;
this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused);
// Terminal was hidden on open
if (!this._isPaused && !this._charSizeService.hasValidSize) {