Merge pull request #5523 from Tyriar/4628

Add idle timeout which pauses cursor blink
This commit is contained in:
Daniel Imms
2025-12-28 14:04:49 -08:00
committed by GitHub
6 changed files with 155 additions and 12 deletions
@@ -3,12 +3,15 @@
* @license MIT
*/
import { RendererConstants } from 'browser/renderer/shared/Constants';
import { ICoreBrowserService } from 'browser/services/Services';
/**
* The time between cursor blinks.
*/
const BLINK_INTERVAL = 600;
const enum Constants {
/**
* The time between cursor blinks.
*/
BLINK_INTERVAL = 600,
}
export class CursorBlinkStateManager {
public isCursorVisible: boolean;
@@ -16,6 +19,8 @@ export class CursorBlinkStateManager {
private _animationFrame: number | undefined;
private _blinkStartTimeout: number | undefined;
private _blinkInterval: number | undefined;
private _idleTimeout: number | undefined;
private _isIdlePaused: boolean = false;
/**
* The time at which the animation frame was restarted, this is used on the
@@ -31,6 +36,7 @@ export class CursorBlinkStateManager {
this.isCursorVisible = true;
if (this._coreBrowserService.isFocused) {
this._restartInterval();
this._resetIdleTimer();
}
}
@@ -49,9 +55,16 @@ export class CursorBlinkStateManager {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
if (this._idleTimeout) {
this._coreBrowserService.window.clearTimeout(this._idleTimeout);
this._idleTimeout = undefined;
}
}
public restartBlinkAnimation(): void {
if (this._isIdlePaused) {
this._resetIdleTimer();
}
if (this.isPaused) {
return;
}
@@ -67,7 +80,7 @@ export class CursorBlinkStateManager {
}
}
private _restartInterval(timeToStart: number = BLINK_INTERVAL): void {
private _restartInterval(timeToStart: number = Constants.BLINK_INTERVAL): void {
// Clear any existing interval
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
@@ -82,7 +95,7 @@ export class CursorBlinkStateManager {
// Check if another animation restart was requested while this was being
// started
if (this._animationTimeRestarted) {
const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
const time = Constants.BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
this._animationTimeRestarted = undefined;
if (time > 0) {
this._restartInterval(time);
@@ -103,7 +116,7 @@ export class CursorBlinkStateManager {
if (this._animationTimeRestarted) {
// calc time diff
// Make restart interval do a setTimeout initially?
const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
const time = Constants.BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
this._animationTimeRestarted = undefined;
this._restartInterval(time);
return;
@@ -115,12 +128,13 @@ export class CursorBlinkStateManager {
this._renderCallback();
this._animationFrame = undefined;
});
}, BLINK_INTERVAL);
}, Constants.BLINK_INTERVAL);
}, timeToStart);
}
public pause(): void {
this.isCursorVisible = true;
this._isIdlePaused = false;
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
this._blinkInterval = undefined;
@@ -133,6 +147,10 @@ export class CursorBlinkStateManager {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
if (this._idleTimeout) {
this._coreBrowserService.window.clearTimeout(this._idleTimeout);
this._idleTimeout = undefined;
}
}
public resume(): void {
@@ -141,6 +159,47 @@ export class CursorBlinkStateManager {
this._animationTimeRestarted = undefined;
this._restartInterval();
this._resetIdleTimer();
this.restartBlinkAnimation();
}
/**
* Resets the idle timer. If the terminal is idle for the idle timeout period,
* the cursor blinking will stop.
*/
private _resetIdleTimer(): void {
this._isIdlePaused = false;
if (this._idleTimeout) {
this._coreBrowserService.window.clearTimeout(this._idleTimeout);
}
this._idleTimeout = this._coreBrowserService.window.setTimeout(() => {
this._stopBlinkingDueToIdle();
}, RendererConstants.CURSOR_BLINK_IDLE_TIMEOUT);
}
/**
* Stops cursor blinking due to idle timeout.
*/
private _stopBlinkingDueToIdle(): void {
// Make cursor visible and stop blinking
this.isCursorVisible = true;
this._isIdlePaused = true;
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
this._blinkInterval = undefined;
}
if (this._blinkStartTimeout) {
this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout);
this._blinkStartTimeout = undefined;
}
if (this._animationFrame) {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
// Clear the idle timeout as we've already acted on it
this._coreBrowserService.window.clearTimeout(this._idleTimeout);
this._idleTimeout = undefined;
// Trigger a render to show the cursor in its final visible state
this._renderCallback();
}
}
+2
View File
@@ -136,6 +136,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._observerDisposable.value = observeDevicePixelDimensions(this._canvas, w, (w, h) => this._setCanvasDevicePixelDimensions(w, h));
}));
this._register(addDisposableListener(this._coreBrowserService.mainDocument, 'mousedown', () => this._cursorBlinkStateManager.value?.restartBlinkAnimation()));
this._core.screenElement!.appendChild(this._canvas);
[this._rectangleRenderer.value, this._glyphRenderer.value] = this._initializeWebGLState();
+73 -2
View File
@@ -5,7 +5,7 @@
import { DomRendererRowFactory, RowCss } from 'browser/renderer/dom/DomRendererRowFactory';
import { WidthCache } from 'browser/renderer/dom/WidthCache';
import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants';
import { INVERTED_DEFAULT_COLOR, RendererConstants } from 'browser/renderer/shared/Constants';
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
import { IRenderDimensions, IRenderer, IRequestRedrawEvent, ISelectionRenderModel } from 'browser/renderer/shared/Types';
@@ -15,6 +15,7 @@ import { color } from 'common/Color';
import { Disposable, toDisposable } from 'vs/base/common/lifecycle';
import { IBufferService, ICoreService, IInstantiationService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'vs/base/common/event';
import { addDisposableListener } from 'vs/base/browser/dom';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
@@ -23,6 +24,7 @@ const FG_CLASS_PREFIX = 'xterm-fg-';
const BG_CLASS_PREFIX = 'xterm-bg-';
const FOCUS_CLASS = 'xterm-focus';
const SELECTION_CLASS = 'xterm-selection';
const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle';
let nextTerminalId = 1;
@@ -42,6 +44,7 @@ export class DomRenderer extends Disposable implements IRenderer {
private _selectionContainer: HTMLElement;
private _widthCache: WidthCache;
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
private _cursorBlinkStateManager: CursorBlinkStateManager;
public dimensions: IRenderDimensions;
@@ -89,6 +92,10 @@ export class DomRenderer extends Disposable implements IRenderer {
this._register(this._linkifier2.onShowLinkUnderline(e => this._handleLinkHover(e)));
this._register(this._linkifier2.onHideLinkUnderline(e => this._handleLinkLeave(e)));
this._cursorBlinkStateManager = new CursorBlinkStateManager(this._rowContainer, this._coreBrowserService);
this._register(addDisposableListener(this._document, 'mousedown', () => this._cursorBlinkStateManager.restartBlinkAnimation()));
this._register(toDisposable(() => this._cursorBlinkStateManager.dispose()));
this._register(toDisposable(() => {
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
@@ -225,6 +232,10 @@ export class DomRenderer extends Disposable implements IRenderer {
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
`}` +
// Disable cursor blinking when idle
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
` animation: none !important;` +
`}` +
// !important helps fix an issue where the cursor will not render on top of the selection,
// however it's very hard to fix this issue and retain the blink animation without the use of
// !important. So this edge case fails when cursor blink is on.
@@ -328,11 +339,13 @@ export class DomRenderer extends Disposable implements IRenderer {
public handleBlur(): void {
this._rowContainer.classList.remove(FOCUS_CLASS);
this._cursorBlinkStateManager.pause();
this.renderRows(0, this._bufferService.rows - 1);
}
public handleFocus(): void {
this._rowContainer.classList.add(FOCUS_CLASS);
this._cursorBlinkStateManager.resume();
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
}
@@ -406,7 +419,8 @@ export class DomRenderer extends Disposable implements IRenderer {
}
public handleCursorMove(): void {
// No-op, the cursor is drawn when rows are drawn
// Reset idle timer on cursor movement (which happens on input)
this._cursorBlinkStateManager.restartBlinkAnimation();
}
private _handleOptionsChanged(): void {
@@ -540,3 +554,60 @@ export class DomRenderer extends Disposable implements IRenderer {
}
}
}
class CursorBlinkStateManager {
private _idleTimeout: number | undefined;
private _isIdlePaused: boolean = false;
constructor(
private readonly _rowContainer: HTMLElement,
private readonly _coreBrowserService: ICoreBrowserService
) {
if (this._coreBrowserService.isFocused) {
this._resetIdleTimer();
}
}
public dispose(): void {
this._clearIdleTimer();
}
public restartBlinkAnimation(): void {
if (this._isIdlePaused) {
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
}
this._resetIdleTimer();
}
public pause(): void {
this._isIdlePaused = false;
this._clearIdleTimer();
}
public resume(): void {
this._isIdlePaused = false;
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
this._resetIdleTimer();
}
private _resetIdleTimer(): void {
this._isIdlePaused = false;
this._clearIdleTimer();
this._idleTimeout = this._coreBrowserService.window.setTimeout(() => {
this._stopBlinkingDueToIdle();
}, RendererConstants.CURSOR_BLINK_IDLE_TIMEOUT);
}
private _clearIdleTimer(): void {
if (this._idleTimeout) {
this._coreBrowserService.window.clearTimeout(this._idleTimeout);
this._idleTimeout = undefined;
}
}
private _stopBlinkingDueToIdle(): void {
this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS);
this._isIdlePaused = true;
this._idleTimeout = undefined;
}
}
+7
View File
@@ -4,3 +4,10 @@
*/
export const INVERTED_DEFAULT_COLOR = 257;
export const enum RendererConstants {
/**
* The idle time after which cursor blinking stops.
*/
CURSOR_BLINK_IDLE_TIMEOUT = 5 * 60 * 1000
}
+3 -1
View File
@@ -49,7 +49,9 @@ declare module '@xterm/headless' {
convertEol?: boolean;
/**
* Whether the cursor blinks.
* Whether the cursor blinks. The blinking will stop after 5 minutes of idle
* time (refreshed by clicking, focusing or the cursor moving). The default
* is false.
*/
cursorBlink?: boolean;
+3 -1
View File
@@ -58,7 +58,9 @@ declare module '@xterm/xterm' {
convertEol?: boolean;
/**
* Whether the cursor blinks.
* Whether the cursor blinks. The blinking will stop after 5 minutes of idle
* time (refreshed by clicking, focusing or the cursor moving). The default
* is false.
*/
cursorBlink?: boolean;