diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 05af6527..351b9f27 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -13,7 +13,8 @@ import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeS import { CharData, IBufferLine, ICellData } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; -import { Attributes, Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { Terminal } from '@xterm/xterm'; import { GlyphRenderer } from './GlyphRenderer'; @@ -30,6 +31,7 @@ import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; private _cursorBlinkStateManager: MutableDisposable = new MutableDisposable(); + private _textBlinkStateManager: TextBlinkStateManager; private _charAtlasDisposable = this._register(new MutableDisposable()); private _charAtlas: ITextureAtlas | undefined; private _devicePixelRatio: number; @@ -105,6 +107,11 @@ export class WebglRenderer extends Disposable implements IRenderer { this._updateDimensions(); this._updateCursorBlink(); this._register(_optionsService.onOptionChange(() => this._handleOptionsChanged())); + this._textBlinkStateManager = this._register(new TextBlinkStateManager( + () => this._requestRedrawViewport(), + this._coreBrowserService, + this._optionsService + )); this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE); @@ -506,6 +513,10 @@ export class WebglRenderer extends Disposable implements IRenderer { } } + if (this._textBlinkStateManager.isEnabled && !this._textBlinkStateManager.isBlinkOn && cell.isBlink()) { + this._cellColorResolver.result.fg |= FgFlags.INVISIBLE; + } + if (code !== NULL_CELL_CODE) { this._model.lineLengths[y] = x + 1; } diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index c564ca4a..50f94e8b 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -8,6 +8,7 @@ import { WidthCache } from 'browser/renderer/dom/WidthCache'; 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 { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent, ISelectionRenderModel } from 'browser/renderer/shared/Types'; import { ICharSizeService, ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { ILinkifier2, ILinkifierEvent, ITerminal, ReadonlyColorSet } from 'browser/Types'; @@ -45,10 +46,12 @@ export class DomRenderer extends Disposable implements IRenderer { private _widthCache: WidthCache; private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel(); private _cursorBlinkStateManager: CursorBlinkStateManager; + private _textBlinkStateManager: TextBlinkStateManager; public dimensions: IRenderDimensions; - public readonly onRequestRedraw = this._register(new Emitter()).event; + private readonly _onRequestRedraw = this._register(new Emitter()); + public readonly onRequestRedraw = this._onRequestRedraw.event; constructor( private readonly _terminal: ITerminal, @@ -95,6 +98,11 @@ export class DomRenderer extends Disposable implements IRenderer { this._cursorBlinkStateManager = new CursorBlinkStateManager(this._rowContainer, this._coreBrowserService); this._register(addDisposableListener(this._document, 'mousedown', () => this._cursorBlinkStateManager.restartBlinkAnimation())); this._register(toDisposable(() => this._cursorBlinkStateManager.dispose())); + this._textBlinkStateManager = this._register(new TextBlinkStateManager( + () => this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 }), + this._coreBrowserService, + this._optionsService + )); this._register(toDisposable(() => { this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass); @@ -193,6 +201,9 @@ export class DomRenderer extends Disposable implements IRenderer { `}` + `${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` + ` font-style: italic;` + + `}` + + `${this._terminalSelector} span.${RowCss.BLINK_HIDDEN_CLASS} {` + + ` visibility: hidden;` + `}`; // Blink animation const blinkAnimationUnderlineId = `blink_underline_${this._terminalClass}`; @@ -476,6 +487,7 @@ export class DomRenderer extends Disposable implements IRenderer { cursorInactiveStyle, cursorX, cursorBlink, + this._textBlinkStateManager.isBlinkOn, this.dimensions.css.cell.width, this._widthCache, -1, @@ -545,6 +557,7 @@ export class DomRenderer extends Disposable implements IRenderer { cursorInactiveStyle, cursorX, cursorBlink, + this._textBlinkStateManager.isBlinkOn, this.dimensions.css.cell.width, this._widthCache, enabled ? (i === y ? x : 0) : -1, diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index ad95037c..b6f6eedc 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -40,7 +40,7 @@ describe('DomRendererRowFactory', () => { describe('createRow', () => { it('should not create anything for an empty row', () => { - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), '' ); @@ -51,7 +51,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)])); // There should be no element for the following "empty" cell lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), '' ); @@ -59,7 +59,7 @@ describe('DomRendererRowFactory', () => { it('should add class for cursor and cursor style', () => { for (const style of ['block', 'bar', 'underline']) { - const spans = rowFactory.createRow(lineData, 0, true, style, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, true, style, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ` ` ); @@ -67,7 +67,7 @@ describe('DomRendererRowFactory', () => { }); it('should add class for cursor blink', () => { - const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, true, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, true, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ` ` ); @@ -86,7 +86,7 @@ describe('DomRendererRowFactory', () => { new MockThemeService() ); for (const inactiveStyle of ['outline', 'block', 'bar', 'underline', 'none']){ - const spans = rowFactory.createRow(lineData, 0, true, 'block', inactiveStyle, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, true, 'block', inactiveStyle, 0, false, true, 5, widthCache, -1, -1); if (inactiveStyle === 'none') { assert.equal(extractHtml(spans), ` `); @@ -109,7 +109,7 @@ describe('DomRendererRowFactory', () => { new MockDecorationService(), new MockThemeService() ); - const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ` ` ); @@ -120,7 +120,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -130,7 +130,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -140,7 +140,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -153,7 +153,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.SINGLE; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -164,7 +164,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DOUBLE; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -175,7 +175,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.CURLY; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -186,7 +186,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DOTTED; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -197,7 +197,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DASHED; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -208,7 +208,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -218,12 +218,28 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); }); + it('should hide blinking text when blink is off', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BLINK | FgFlags.UNDERLINE; + cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; + cell.extended.underlineStyle = UnderlineStyle.SINGLE; + lineData.setCell(0, cell); + const onSpans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); + assert.equal(extractHtml(onSpans), + 'a' + ); + const offSpans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, false, 5, widthCache, -1, -1); + assert.equal(extractHtml(offSpans), + 'a' + ); + }); + it('should add classes for 256 foreground colors', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg |= Attributes.CM_P256; @@ -231,7 +247,7 @@ describe('DomRendererRowFactory', () => { cell.fg &= ~Attributes.PCOLOR_MASK; cell.fg |= i; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), `a` ); @@ -245,7 +261,7 @@ describe('DomRendererRowFactory', () => { cell.bg &= ~Attributes.PCOLOR_MASK; cell.bg |= i; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), `a` ); @@ -257,7 +273,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE; cell.bg |= Attributes.CM_P16 | 1; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -268,7 +284,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= FgFlags.INVERSE; cell.bg |= Attributes.CM_P16 | 1; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -278,7 +294,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -291,7 +307,7 @@ describe('DomRendererRowFactory', () => { cell.fg &= ~Attributes.PCOLOR_MASK; cell.fg |= i; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), `a` ); @@ -303,7 +319,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3; cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -314,7 +330,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE; cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6; lineData.setCell(0, cell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'a' ); @@ -326,7 +342,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); rowFactory.handleSelectionChanged([1, 0], [2, 0], false); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'ab' ); @@ -334,7 +350,7 @@ describe('DomRendererRowFactory', () => { it('should force whitespace cells to be rendered above the background', () => { lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); rowFactory.handleSelectionChanged([0, 0], [2, 0], false); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' a' ); @@ -351,7 +367,7 @@ describe('DomRendererRowFactory', () => { }); it('should not create anything for an empty row', () => { - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), '' ); @@ -361,7 +377,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'abc' ); @@ -372,7 +388,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'ac' ); @@ -387,7 +403,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(1, aColor1); lineData.setCell(2, bColor2); lineData.setCell(3, bColor2); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'aabb' ); @@ -399,7 +415,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)])); lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'aaXbb' ); @@ -412,7 +428,7 @@ describe('DomRendererRowFactory', () => { nullCell.bg = Attributes.CM_P16 | 2; lineData.setCell(3, nullCell); lineData.setCell(4, nullCell); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' ' ); @@ -422,23 +438,23 @@ describe('DomRendererRowFactory', () => { const nullCell = lineData.loadCell(0, new CellData()); nullCell.bg = Attributes.CM_P16 | 1; lineData.setCell(0, nullCell); - let spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + let spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' ' ); lineData.setCell(1, nullCell); - spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' ' ); lineData.setCell(2, nullCell); lineData.setCell(3, nullCell); - spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' ' ); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); - spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), ' a' ); @@ -451,7 +467,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)])); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), 'ac語𝄞' ); @@ -465,7 +481,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, 2, 4); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4); assert.equal(extractHtml(spans), 'aaxxxbb' ); @@ -476,7 +492,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)])); - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, 2, 4); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4); assert.equal(extractHtml(spans), 'aax x' ); @@ -486,7 +502,7 @@ describe('DomRendererRowFactory', () => { for (let i = 0; i < 10; ++i) { lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); } - const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -100, 100); + const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -100, 100); assert.equal(extractHtml(spans), 'aaaaaaaaaa' ); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index e69c317b..31efa7dc 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -24,6 +24,7 @@ export const enum RowCss { UNDERLINE_CLASS = 'xterm-underline', OVERLINE_CLASS = 'xterm-overline', STRIKETHROUGH_CLASS = 'xterm-strikethrough', + BLINK_HIDDEN_CLASS = 'xterm-blink-hidden', CURSOR_CLASS = 'xterm-cursor', CURSOR_BLINK_CLASS = 'xterm-cursor-blink', CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block', @@ -66,6 +67,7 @@ export class DomRendererRowFactory { cursorInactiveStyle: string | undefined, cursorX: number, cursorBlink: boolean, + blinkOn: boolean, cellWidth: number, widthCache: WidthCache, linkStart: number, @@ -153,6 +155,10 @@ export class DomRendererRowFactory { const isInSelection = this._isCellInSelection(x, row); const isCursorCell = isCursorRow && x === cursorX; const isLinkHover = hasHover && x >= linkStart && x <= linkEnd; + const isBlinkHidden = !blinkOn && cell.isBlink(); + if (isBlinkHidden) { + classes.push(RowCss.BLINK_HIDDEN_CLASS); + } let isDecorated = false; this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { diff --git a/src/browser/renderer/shared/TextBlinkStateManager.ts b/src/browser/renderer/shared/TextBlinkStateManager.ts new file mode 100644 index 00000000..564bbdd9 --- /dev/null +++ b/src/browser/renderer/shared/TextBlinkStateManager.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICoreBrowserService } from 'browser/services/Services'; +import { Disposable, toDisposable } from 'common/Lifecycle'; +import { IOptionsService } from 'common/services/Services'; + +export class TextBlinkStateManager extends Disposable { + private _intervalDuration: number = 0; + private _interval: number | undefined; + private _blinkOn: boolean = true; + + constructor( + private readonly _renderCallback: () => void, + private readonly _coreBrowserService: ICoreBrowserService, + private readonly _optionsService: IOptionsService + ) { + super(); + this._register(this._optionsService.onSpecificOptionChange('blinkIntervalDuration', duration => { + this.setIntervalDuration(duration); + })); + this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration); + this._register(toDisposable(() => this._clearInterval())); + } + + public get isBlinkOn(): boolean { + return this._blinkOn; + } + + public get isEnabled(): boolean { + return this._intervalDuration > 0; + } + + public setIntervalDuration(duration: number): void { + if (duration === this._intervalDuration) { + return; + } + + this._intervalDuration = duration; + this._clearInterval(); + + const wasBlinkOn = this._blinkOn; + if (duration > 0) { + this._blinkOn = true; + this._interval = this._coreBrowserService.window.setInterval(() => { + this._blinkOn = !this._blinkOn; + this._renderCallback(); + }, duration); + if (!wasBlinkOn) { + this._renderCallback(); + } + } else { + this._blinkOn = true; + if (!wasBlinkOn) { + this._renderCallback(); + } + } + } + + private _clearInterval(): void { + if (this._interval !== undefined) { + this._coreBrowserService.window.clearInterval(this._interval); + this._interval = undefined; + } + } +} diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 7ddc1c06..394c12da 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -14,6 +14,7 @@ export const DEFAULT_OPTIONS: Readonly> = { rows: 24, showCursorImmediately: false, cursorBlink: false, + blinkIntervalDuration: 0, cursorStyle: 'block', cursorWidth: 1, cursorInactiveStyle: 'outline', @@ -168,6 +169,12 @@ export class OptionsService extends Disposable implements IOptionsService { } value = FONT_WEIGHT_OPTIONS.includes(value) ? value : DEFAULT_OPTIONS[key]; break; + case 'blinkIntervalDuration': + value = Math.floor(value); + if (value < 0) { + throw new Error(`${key} cannot be less than 0, value: ${value}`); + } + break; case 'cursorWidth': value = Math.floor(value); // Fall through for bounds check diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 9ef0fe9a..9eb87bd6 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -229,6 +229,7 @@ export interface ITerminalOptions { cols?: number; convertEol?: boolean; cursorBlink?: boolean; + blinkIntervalDuration?: number; cursorStyle?: CursorStyle; cursorWidth?: number; cursorInactiveStyle?: CursorInactiveStyle; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 99c20be4..9cd46c07 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -55,6 +55,13 @@ declare module '@xterm/headless' { */ cursorBlink?: boolean; + /** + * The interval in milliseconds for the blink attribute. This is the amount + * of time text remains visible or hidden before toggling. Set to 0 to + * disable blinking. The default is 0. + */ + blinkIntervalDuration?: number; + /** * The style of the cursor. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 19ab3da0..5d2e8f11 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -64,6 +64,13 @@ declare module '@xterm/xterm' { */ cursorBlink?: boolean; + /** + * The interval in milliseconds for the blink attribute. This is the amount + * of time text remains visible or hidden before toggling. Set to 0 to + * disable blinking. The default is 0. + */ + blinkIntervalDuration?: number; + /** * The style of the cursor when the terminal is focused. */