diff --git a/addons/addon-webgl/src/CursorBlinkStateManager.ts b/addons/addon-webgl/src/CursorBlinkStateManager.ts index c5bb0870..688aee7b 100644 --- a/addons/addon-webgl/src/CursorBlinkStateManager.ts +++ b/addons/addon-webgl/src/CursorBlinkStateManager.ts @@ -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(); + } } diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index fb14b3c0..987d587c 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -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(); diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 1c271797..b1c136f2 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -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; + } +} diff --git a/src/browser/renderer/shared/Constants.ts b/src/browser/renderer/shared/Constants.ts index 4bc4f985..5b6665c9 100644 --- a/src/browser/renderer/shared/Constants.ts +++ b/src/browser/renderer/shared/Constants.ts @@ -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 +} diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 819a46d9..2f304604 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -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; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 6afdf48e..959b4afe 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -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;