diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 1eb126e5..0f356e14 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -77,7 +77,7 @@ export class SearchAddon implements ITerminalAddon { public activate(terminal: Terminal): void { this._terminal = terminal; - this._onDataDisposable = this._terminal.onData(() => this._updateMatches()); + this._onDataDisposable = this._terminal.onWriteParsed(() => this._updateMatches()); this._onResizeDisposable = this._terminal.onResize(() => this._updateMatches()); } diff --git a/addons/xterm-addon-web-links/test/tsconfig.json b/addons/xterm-addon-web-links/test/tsconfig.json index 1c772984..9f4d23df 100644 --- a/addons/xterm-addon-web-links/test/tsconfig.json +++ b/addons/xterm-addon-web-links/test/tsconfig.json @@ -12,8 +12,7 @@ "strict": true, "types": [ "../../../node_modules/@types/mocha", - "../../../node_modules/@types/node", - "../../../out-test/api/TestUtils" + "../../../node_modules/@types/node" ] }, "include": [ diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index e437ca90..8edda780 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -167,7 +167,6 @@ export class WebglRenderer extends Disposable implements IRenderer { this._core.screenElement!.style.height = `${this.dimensions.canvasHeight}px`; this._rectangleRenderer.onResize(); - this._glyphRenderer.setDimensions(this.dimensions); this._glyphRenderer.onResize(); diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 89542936..acbf1c93 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -303,7 +303,7 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { // Add listener for rerendering if (this._renderService) { - this._linkCacheDisposables.push(this._renderService.onRenderedBufferChange(e => { + this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e => { // When start is 0 a scroll most likely occurred, make sure links above the fold also get // cleared. const start = e.start === 0 ? 0 : e.start + 1 + this._bufferService.buffer.ydisp; diff --git a/src/browser/RenderDebouncer.ts b/src/browser/RenderDebouncer.ts index 02521070..ad2d79b4 100644 --- a/src/browser/RenderDebouncer.ts +++ b/src/browser/RenderDebouncer.ts @@ -3,16 +3,17 @@ * @license MIT */ -import { IRenderDebouncer } from 'browser/Types'; +import { IRenderDebouncerWithCallback } from 'browser/Types'; /** * Debounces calls to render terminal rows using animation frames. */ -export class RenderDebouncer implements IRenderDebouncer { +export class RenderDebouncer implements IRenderDebouncerWithCallback { private _rowStart: number | undefined; private _rowEnd: number | undefined; private _rowCount: number | undefined; private _animationFrame: number | undefined; + private _refreshCallbacks: FrameRequestCallback[] = []; constructor( private _renderCallback: (start: number, end: number) => void @@ -26,6 +27,14 @@ export class RenderDebouncer implements IRenderDebouncer { } } + public addRefreshCallback(callback: FrameRequestCallback): number { + this._refreshCallbacks.push(callback); + if (!this._animationFrame) { + this._animationFrame = window.requestAnimationFrame(() => this._innerRefresh()); + } + return this._animationFrame; + } + public refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void { this._rowCount = rowCount; // Get the min/max row start/end for the arg values @@ -43,8 +52,11 @@ export class RenderDebouncer implements IRenderDebouncer { } private _innerRefresh(): void { + this._animationFrame = undefined; + // Make sure values are set if (this._rowStart === undefined || this._rowEnd === undefined || this._rowCount === undefined) { + this._runRefreshCallbacks(); return; } @@ -55,9 +67,16 @@ export class RenderDebouncer implements IRenderDebouncer { // Reset debouncer (this happens before render callback as the render could trigger it again) this._rowStart = undefined; this._rowEnd = undefined; - this._animationFrame = undefined; // Run render callback this._renderCallback(start, end); + this._runRefreshCallbacks(); + } + + private _runRefreshCallbacks(): void { + for (const callback of this._refreshCallbacks) { + callback(0); + } + this._refreshCallbacks = []; } } diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index de3fff90..9ab3087c 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -526,7 +526,7 @@ export class Terminal extends CoreTerminal implements ITerminal { const renderer = this._createRenderer(); this._renderService = this.register(this._instantiationService.createInstance(RenderService, renderer, this.rows, this.screenElement)); this._instantiationService.setService(IRenderService, this._renderService); - this.register(this._renderService.onRenderedBufferChange(e => this._onRender.fire(e))); + this.register(this._renderService.onRenderedViewportChange(e => this._onRender.fire(e))); this.onResize(e => this._renderService!.resize(e.cols, e.rows)); this._compositionView = document.createElement('div'); diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index f7e6bcb4..1ba175f3 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -7,7 +7,7 @@ import { IDisposable, IMarker, ISelectionPosition, ILinkProvider, IDecorationOpt import { IEvent, EventEmitter } from 'common/EventEmitter'; import { ICharacterJoinerService, ICharSizeService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/Types'; -import { IColorSet, ILinkMatcherOptions, ITerminal, ILinkifier, ILinkifier2, IBrowser, IViewport, IColorManager, ICompositionHelper, CharacterJoinerHandler } from 'browser/Types'; +import { IColorSet, ILinkMatcherOptions, ITerminal, ILinkifier, ILinkifier2, IBrowser, IViewport, IColorManager, ICompositionHelper, CharacterJoinerHandler, IRenderDebouncer } from 'browser/Types'; import { IBuffer, IBufferStringIterator, IBufferSet } from 'common/buffer/Types'; import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset, ITerminalOptions } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; @@ -31,6 +31,7 @@ export class MockTerminal implements ITerminal { public onBlur!: IEvent; public onFocus!: IEvent; public onA11yChar!: IEvent; + public onWriteParsed!: IEvent; public onA11yTab!: IEvent; public onCursorMove!: IEvent; public onLineFeed!: IEvent; @@ -371,7 +372,7 @@ export class MockMouseService implements IMouseService { export class MockRenderService implements IRenderService { public serviceBrand: undefined; public onDimensionsChange: IEvent = new EventEmitter().event; - public onRenderedBufferChange: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event; + public onRenderedViewportChange: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event; public onRender: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event; public onRefreshRequest: IEvent<{ start: number, end: number}, void> = new EventEmitter<{ start: number, end: number }>().event; public dimensions: IRenderDimensions = { @@ -391,15 +392,15 @@ export class MockRenderService implements IRenderService { public refreshRows(start: number, end: number): void { throw new Error('Method not implemented.'); } + public addRefreshCallback(callback: FrameRequestCallback): number { + throw new Error('Method not implemented.'); + } public clearTextureAtlas(): void { throw new Error('Method not implemented.'); } public resize(cols: number, rows: number): void { throw new Error('Method not implemented.'); } - public changeOptions(): void { - throw new Error('Method not implemented.'); - } public setRenderer(renderer: IRenderer): void { throw new Error('Method not implemented.'); } diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 129842d5..4e203ab7 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -44,6 +44,7 @@ export interface IPublicTerminal extends IDisposable { onSelectionChange: IEvent; onRender: IEvent<{ start: number, end: number }>; onResize: IEvent<{ cols: number, rows: number }>; + onWriteParsed: IEvent; onTitleChange: IEvent; onBell: IEvent; blur(): void; @@ -310,3 +311,7 @@ export interface ICharacterJoiner { export interface IRenderDebouncer extends IDisposable { refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void; } + +export interface IRenderDebouncerWithCallback extends IRenderDebouncer { + addRefreshCallback(callback: FrameRequestCallback): number; +} diff --git a/src/browser/decorations/BufferDecorationRenderer.ts b/src/browser/decorations/BufferDecorationRenderer.ts index ac3457f3..632a2864 100644 --- a/src/browser/decorations/BufferDecorationRenderer.ts +++ b/src/browser/decorations/BufferDecorationRenderer.ts @@ -14,6 +14,7 @@ export class BufferDecorationRenderer extends Disposable { private _animationFrame: number | undefined; private _altBufferIsActive: boolean = false; + private _dimensionsChanged: boolean = false; constructor( private readonly _screenElement: HTMLElement, @@ -27,8 +28,11 @@ export class BufferDecorationRenderer extends Disposable { this._container.classList.add('xterm-decoration-container'); this._screenElement.appendChild(this._container); - this.register(this._renderService.onRenderedBufferChange(() => this._queueRefresh())); - this.register(this._renderService.onDimensionsChange(() => this._queueRefresh())); + this.register(this._renderService.onRenderedViewportChange(() => this._queueRefresh())); + this.register(this._renderService.onDimensionsChange(() => { + this._dimensionsChanged = true; + this._queueRefresh(); + })); this.register(addDisposableDomListener(window, 'resize', () => this._queueRefresh())); this.register(this._bufferService.buffers.onBufferActivate(() => { this._altBufferIsActive = this._bufferService.buffer === this._bufferService.buffers.alt; @@ -47,7 +51,7 @@ export class BufferDecorationRenderer extends Disposable { if (this._animationFrame !== undefined) { return; } - this._animationFrame = window.requestAnimationFrame(() => { + this._animationFrame = this._renderService.addRefreshCallback(() => { this.refreshDecorations(); this._animationFrame = undefined; }); @@ -57,10 +61,14 @@ export class BufferDecorationRenderer extends Disposable { for (const decoration of this._decorationService.decorations) { this._renderDecoration(decoration); } + this._dimensionsChanged = false; } private _renderDecoration(decoration: IInternalDecoration): void { this._refreshStyle(decoration); + if (this._dimensionsChanged) { + this._refreshXPosition(decoration); + } } private _createElement(decoration: IInternalDecoration): HTMLElement { @@ -76,11 +84,7 @@ export class BufferDecorationRenderer extends Disposable { // exceeded the container width, so hide element.style.display = 'none'; } - if ((decoration.options.anchor || 'left') === 'right') { - element.style.right = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; - } else { - element.style.left = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; - } + this._refreshXPosition(decoration); return element; } @@ -108,6 +112,18 @@ export class BufferDecorationRenderer extends Disposable { } } + private _refreshXPosition(decoration: IInternalDecoration): void { + if (!decoration.element) { + return; + } + const x = decoration.options.x ?? 0; + if ((decoration.options.anchor || 'left') === 'right') { + decoration.element.style.right = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; + } else { + decoration.element.style.left = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; + } + } + private _removeDecoration(decoration: IInternalDecoration): void { this._decorationElements.get(decoration)?.remove(); this._decorationElements.delete(decoration); diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index 39480ca2..f31409ca 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -82,7 +82,7 @@ export class OverviewRulerRenderer extends Disposable { * and hide the canvas if the alt buffer is active */ private _registerBufferChangeListeners(): void { - this.register(this._renderService.onRenderedBufferChange(() => this._queueRefresh())); + this.register(this._renderService.onRenderedViewportChange(() => this._queueRefresh())); this.register(this._bufferService.buffers.onBufferActivate(() => { this._canvas!.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; })); diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 1acde934..187bd3b5 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -74,6 +74,7 @@ export class Terminal implements ITerminalApi { public get onScroll(): IEvent { return this._core.onScroll; } public get onSelectionChange(): IEvent { return this._core.onSelectionChange; } public get onTitleChange(): IEvent { return this._core.onTitleChange; } + public get onWriteParsed(): IEvent { return this._core.onWriteParsed; } public get element(): HTMLElement | undefined { return this._core.element; } public get parser(): IParser { diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index b2e619fe..78bd7f56 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -9,7 +9,7 @@ import { EventEmitter, IEvent } from 'common/EventEmitter'; import { Disposable } from 'common/Lifecycle'; import { ScreenDprMonitor } from 'browser/ScreenDprMonitor'; import { addDisposableDomListener } from 'browser/Lifecycle'; -import { IColorSet, IRenderDebouncer } from 'browser/Types'; +import { IColorSet, IRenderDebouncer, IRenderDebouncerWithCallback } from 'browser/Types'; import { IOptionsService, IBufferService, IDecorationService } from 'common/services/Services'; import { ICharSizeService, IRenderService } from 'browser/services/Services'; @@ -22,7 +22,7 @@ interface ISelectionState { export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; - private _renderDebouncer: IRenderDebouncer; + private _renderDebouncer: IRenderDebouncerWithCallback; private _screenDprMonitor: ScreenDprMonitor; private _isPaused: boolean = false; @@ -39,8 +39,8 @@ export class RenderService extends Disposable implements IRenderService { private _onDimensionsChange = new EventEmitter(); public get onDimensionsChange(): IEvent { return this._onDimensionsChange.event; } - private _onRenderedBufferChange = new EventEmitter<{ start: number, end: number }>(); - public get onRenderedBufferChange(): IEvent<{ start: number, end: number }> { return this._onRenderedBufferChange.event; } + private _onRenderedViewportChange = new EventEmitter<{ start: number, end: number }>(); + public get onRenderedViewportChange(): IEvent<{ start: number, end: number }> { return this._onRenderedViewportChange.event; } private _onRender = new EventEmitter<{ start: number, end: number }>(); public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } private _onRefreshRequest = new EventEmitter<{ start: number, end: number }>(); @@ -70,7 +70,7 @@ export class RenderService extends Disposable implements IRenderService { this.register(bufferService.onResize(() => this._fullRefresh())); this.register(bufferService.buffers.onBufferActivate(() => this._renderer?.clear())); - this.register(optionsService.onOptionChange(() => this._renderer.onOptionsChanged())); + this.register(optionsService.onOptionChange(() => this._handleOptionsChanged())); this.register(this._charSizeService.onCharSizeChange(() => this.onCharSizeChanged())); // Do a full refresh whenever any decoration is added or removed. This may not actually result @@ -131,7 +131,7 @@ export class RenderService extends Disposable implements IRenderService { // Fire render event only if it was not a redraw if (!this._isNextRenderRedrawOnly) { - this._onRenderedBufferChange.fire({ start, end }); + this._onRenderedViewportChange.fire({ start, end }); } this._onRender.fire({ start, end }); this._isNextRenderRedrawOnly = true; @@ -142,7 +142,7 @@ export class RenderService extends Disposable implements IRenderService { this._fireOnCanvasResize(); } - public changeOptions(): void { + private _handleOptionsChanged(): void { this._renderer.onOptionsChanged(); this.refreshRows(0, this._rowCount - 1); this._fireOnCanvasResize(); @@ -171,6 +171,10 @@ export class RenderService extends Disposable implements IRenderService { this._fullRefresh(); } + public addRefreshCallback(callback: FrameRequestCallback): number { + return this._renderDebouncer.addRefreshCallback(callback); + } + private _fullRefresh(): void { if (this._isPaused) { this._needsFullRefresh = true; diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index c5328f76..e1fb5dbd 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -5,7 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IRenderDimensions, IRenderer } from 'browser/renderer/Types'; -import { IColorSet } from 'browser/Types'; +import { IColorSet, IRenderDebouncer } from 'browser/Types'; import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import { IDisposable } from 'common/Types'; @@ -49,7 +49,7 @@ export interface IRenderService extends IDisposable { * Fires when buffer changes are rendered. This does not fire when only cursor * or selections are rendered. */ - onRenderedBufferChange: IEvent<{ start: number, end: number }>; + onRenderedViewportChange: IEvent<{ start: number, end: number }>; /** * Fires on render */ @@ -58,10 +58,11 @@ export interface IRenderService extends IDisposable { dimensions: IRenderDimensions; + addRefreshCallback(callback: FrameRequestCallback): number; + refreshRows(start: number, end: number): void; clearTextureAtlas(): void; resize(cols: number, rows: number): void; - changeOptions(): void; setRenderer(renderer: IRenderer): void; setColors(colors: IColorSet): void; onDevicePixelRatioChange(): void; diff --git a/src/common/Color.ts b/src/common/Color.ts index b197cd66..a2a3cbad 100644 --- a/src/common/Color.ts +++ b/src/common/Color.ts @@ -150,15 +150,42 @@ export namespace rgb { * Helper functions where the source type is "rgba" (number: 0xrrggbbaa). */ export namespace rgba { + /** + * Given a foreground color and a background color, either increase or reduce the luminance of the + * foreground color until the specified contrast ratio is met. If pure white or black is hit + * without the contrast ratio being met, go the other direction using the background color as the + * foreground color and take either the first or second result depending on which has the higher + * contrast ratio. + * + * `undefined` will be returned if the contrast ratio is already met. + * + * @param bgRgba The background color in rgba format. + * @param fgRgba The foreground color in rgba format. + * @param ratio The contrast ratio to achieve. + */ export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined { const bgL = rgb.relativeLuminance(bgRgba >> 8); const fgL = rgb.relativeLuminance(fgRgba >> 8); const cr = contrastRatio(bgL, fgL); if (cr < ratio) { if (fgL < bgL) { - return reduceLuminance(bgRgba, fgRgba, ratio); + const resultA = reduceLuminance(bgRgba, fgRgba, ratio); + const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8)); + if (resultARatio < ratio) { + const resultB = increaseLuminance(bgRgba, bgRgba, ratio); + const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8)); + return resultARatio > resultBRatio ? resultA : resultB; + } + return resultA; } - return increaseLuminance(bgRgba, fgRgba, ratio); + const resultA = increaseLuminance(bgRgba, fgRgba, ratio); + const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8)); + if (resultARatio < ratio) { + const resultB = reduceLuminance(bgRgba, bgRgba, ratio); + const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8)); + return resultARatio > resultBRatio ? resultA : resultB; + } + return resultA; } return undefined; } diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 12b374c8..af9ec3f9 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -68,6 +68,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { private _onResize = new EventEmitter<{ cols: number, rows: number }>(); public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } protected _onScroll = new EventEmitter(); + public get onWriteParsed(): IEvent { return this._onWriteParsed.event; } + protected _onWriteParsed = new EventEmitter(); /** * Internally we track the source of the scroll but this is meaningless outside the library so * it's filtered out. @@ -138,6 +140,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { // Setup WriteBuffer this._writeBuffer = new WriteBuffer((data, promiseResult) => this._inputHandler.parse(data, promiseResult)); + this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed)); } public dispose(): void { diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index cc84c9ab..67fd751e 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -4,6 +4,8 @@ * @license MIT */ +import { EventEmitter, IEvent } from 'common/EventEmitter'; + declare const setTimeout: (handler: () => void, timeout?: number) => void; /** @@ -44,6 +46,8 @@ export class WriteBuffer { private _bufferOffset = 0; private _isSyncWriting = false; private _syncCalls = 0; + public get onWriteParsed(): IEvent { return this._onWriteParsed.event; } + private _onWriteParsed = new EventEmitter(); constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise) { } @@ -220,5 +224,6 @@ export class WriteBuffer { this._pendingData = 0; this._bufferOffset = 0; } + this._onWriteParsed.fire(); } } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index ce6c8d3f..39dcaee9 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -841,6 +841,17 @@ declare module 'xterm' { */ onRender: IEvent<{ start: number, end: number }>; + /** + * Adds an event listener for when data has been parsed by the terminal, + * after {@link write} is called. This event is useful to listen for any + * changes in the buffer. + * + * This fires at most once per frame, after data parsing completes. Note + * that this can fire when there are still writes pending if there is a lot + * of data. + */ + onWriteParsed: IEvent; + /** * Adds an event listener for when the terminal is resized. The event value * contains the new size.