diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 351b9f27..ec7e0579 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -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); } /** diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 50f94e8b..6a1cc453 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -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); } } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 31efa7dc..1f276c78 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -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); diff --git a/src/browser/renderer/shared/TextBlinkStateManager.test.ts b/src/browser/renderer/shared/TextBlinkStateManager.test.ts new file mode 100644 index 00000000..5c8ce448 --- /dev/null +++ b/src/browser/renderer/shared/TextBlinkStateManager.test.ts @@ -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 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().event, + onWindowChange: new Emitter().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); + }); +}); diff --git a/src/browser/renderer/shared/TextBlinkStateManager.ts b/src/browser/renderer/shared/TextBlinkStateManager.ts index 564bbdd9..028cde89 100644 --- a/src/browser/renderer/shared/TextBlinkStateManager.ts +++ b/src/browser/renderer/shared/TextBlinkStateManager.ts @@ -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(); } } diff --git a/src/browser/renderer/shared/Types.ts b/src/browser/renderer/shared/Types.ts index f86cd4fe..c78e7794 100644 --- a/src/browser/renderer/shared/Types.ts +++ b/src/browser/renderer/shared/Types.ts @@ -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; diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 848eebcc..63f83c5b 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -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) {