diff --git a/css/xterm.css b/css/xterm.css index ab3965b4..7432fbb1 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -178,3 +178,10 @@ z-index: 6; position: absolute; } + +.xterm-decoration-overview-ruler { + z-index: 7; + position: absolute; + top: 0; + right: 0; +} diff --git a/demo/client.ts b/demo/client.ts index aee23401..2ae649e4 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -151,6 +151,7 @@ if (document.location.pathname === '/test') { document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); document.getElementById('add-decoration').addEventListener('click', addDecoration); + document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); } function createTerminal(): void { @@ -544,9 +545,23 @@ function loadTest() { } function addDecoration() { + term.options['overviewRulerWidth'] = 15; const marker = term.addMarker(1); - const decoration = term.registerDecoration({ marker }); - decoration.onRender(() => { - decoration.element.style.backgroundColor = 'red'; + const decoration = term.registerDecoration({ marker, overviewRulerOptions: { color: '#ef2929'} }); + decoration.onRender((e) => { + if (e.classList.value === 'xterm-decoration') { + e.style.backgroundColor = '#ef2929'; + } }); } + +function addOverviewRuler() { + term.options['overviewRulerWidth'] = 15; + term.registerDecoration({marker: term.addMarker(1), overviewRulerOptions: { color: '#ef2929' }}); + term.registerDecoration({marker: term.addMarker(3), overviewRulerOptions: { color: '#8ae234' }}); + term.registerDecoration({marker: term.addMarker(5), overviewRulerOptions: { color: '#729fcf' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#ef2929', position: 'left' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#8ae234', position: 'center' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#729fcf', position: 'right' }}); +} + diff --git a/demo/index.html b/demo/index.html index e024222c..d2b8d481 100644 --- a/demo/index.html +++ b/demo/index.html @@ -69,6 +69,7 @@ + diff --git a/src/browser/Decorations/BufferDecorationRenderer.ts b/src/browser/Decorations/BufferDecorationRenderer.ts new file mode 100644 index 00000000..116c09a5 --- /dev/null +++ b/src/browser/Decorations/BufferDecorationRenderer.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableDomListener } from 'browser/Lifecycle'; +import { IRenderService } from 'browser/services/Services'; +import { Disposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration } from 'common/services/Services'; + +export class BufferDecorationRenderer extends Disposable { + private readonly _container: HTMLElement; + private readonly _decorationElements: Map = new Map(); + + private _animationFrame: number | undefined; + private _altBufferIsActive: boolean = false; + + constructor( + private readonly _screenElement: HTMLElement, + @IBufferService private readonly _bufferService: IBufferService, + @IDecorationService private readonly _decorationService: IDecorationService, + @IRenderService private readonly _renderService: IRenderService + ) { + super(); + + this._container = document.createElement('div'); + 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(addDisposableDomListener(window, 'resize', () => this._queueRefresh())); + this.register(this._bufferService.buffers.onBufferActivate(() => { + this._altBufferIsActive = this._bufferService.buffer === this._bufferService.buffers.alt; + })); + this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh())); + this.register(this._decorationService.onDecorationRemoved(decoration => this._removeDecoration(decoration))); + } + + public override dispose(): void { + this._container.remove(); + this._decorationElements.clear(); + super.dispose(); + } + + private _queueRefresh(): void { + if (this._animationFrame !== undefined) { + return; + } + this._animationFrame = window.requestAnimationFrame(() => { + this.refreshDecorations(); + this._animationFrame = undefined; + }); + } + + public refreshDecorations(): void { + for (const decoration of this._decorationService.decorations) { + this._renderDecoration(decoration); + } + } + + private _renderDecoration(decoration: IInternalDecoration): void { + let element = this._decorationElements.get(decoration); + if (!element) { + element = this._createElement(decoration); + decoration.onDispose(() => this._removeDecoration(decoration)); + decoration.marker.onDispose(() => decoration.dispose()); + decoration.element = element; + this._decorationElements.set(decoration, element); + this._container.appendChild(element); + } + this._refreshStyle(decoration, element); + decoration.onRenderEmitter.fire(element); + } + + private _createElement(decoration: IInternalDecoration): HTMLElement { + const element = document.createElement('div'); + element.classList.add('xterm-decoration'); + element.style.width = `${(decoration.options.width || 1) * this._renderService.dimensions.actualCellWidth}px`; + element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.actualCellHeight}px`; + element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.actualCellHeight}px`; + element.style.lineHeight = `${this._renderService.dimensions.actualCellHeight}px`; + + const x = decoration.options.x ?? 0; + if (x && x > this._bufferService.cols) { + // 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` : ''; + } + + return element; + } + + private _refreshStyle(decoration: IInternalDecoration, element: HTMLElement): void { + const line = decoration.marker.line - this._bufferService.buffers.active.ydisp; + if (line < 0 || line > this._bufferService.rows) { + // outside of viewport + element.style.display = 'none'; + } else { + element.style.top = `${line * this._renderService.dimensions.actualCellHeight}px`; + element.style.display = this._altBufferIsActive ? 'none' : 'block'; + } + } + + 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 new file mode 100644 index 00000000..9f8ada32 --- /dev/null +++ b/src/browser/Decorations/OverviewRulerRenderer.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableDomListener } from 'browser/Lifecycle'; +import { IRenderService } from 'browser/services/Services'; +import { Disposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, IOptionsService } from 'common/services/Services'; + +// This is used to reduce memory usage +// when refreshStyle is called +// by storing and updating +// the sizes of the decorations to be drawn +const renderSizes = new Uint16Array(3); +const enum SizeIndex { + OUTER_SIZE = 0, + INNER_SIZE = 0 +} + +export class OverviewRulerRenderer extends Disposable { + private readonly _canvas: HTMLCanvasElement; + private readonly _ctx: CanvasRenderingContext2D; + private readonly _decorationElements: Map = new Map(); + private get _width(): number { + return this._optionsService.options.overviewRulerWidth || 0; + } + private _animationFrame: number | undefined; + + constructor( + private readonly _viewportElement: HTMLElement, + private readonly _screenElement: HTMLElement, + @IBufferService private readonly _bufferService: IBufferService, + @IDecorationService private readonly _decorationService: IDecorationService, + @IRenderService private readonly _renderService: IRenderService, + @IOptionsService private readonly _optionsService: IOptionsService + ) { + super(); + this._canvas = document.createElement('canvas'); + this._canvas.classList.add('xterm-decoration-overview-ruler'); + this._viewportElement.parentElement?.insertBefore(this._canvas, this._viewportElement); + const ctx = this._canvas.getContext('2d'); + if (!ctx) { + throw new Error('Ctx cannot be null'); + } else { + this._ctx = ctx; + } + this._queueRefresh(true); + this.register(this._bufferService.buffers.onBufferActivate(() => { + this._canvas!.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; + })); + this.register(this._renderService.onRenderedBufferChange(() => this._queueRefresh())); + this.register(this._renderService.onDimensionsChange(() => this._queueRefresh(true, true))); + this.register(addDisposableDomListener(window, 'resize', () => this._queueRefresh(true))); + this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh(undefined, true))); + this.register(this._decorationService.onDecorationRemoved(decoration => this._removeDecoration(decoration))); + this.register(this._optionsService.onOptionChange(o => { + if (o === 'overviewRulerWidth') { + renderSizes[SizeIndex.OUTER_SIZE] = Math.floor(this._width / 3); + renderSizes[SizeIndex.INNER_SIZE] = Math.ceil(this._width / 3); + this._queueRefresh(); + } + })); + console.log(this._width/3); + renderSizes[SizeIndex.OUTER_SIZE] = Math.floor(this._width / 3); + renderSizes[SizeIndex.INNER_SIZE] = Math.ceil(this._width / 3); + } + + public override dispose(): void { + for (const decoration of this._decorationElements) { + this._ctx?.clearRect( + 0, + Math.round(this._canvas.height * (decoration[0].marker.line / this._bufferService.buffers.active.lines.length)), + this._canvas.width, + window.devicePixelRatio + ); + } + this._decorationElements.clear(); + this._canvas?.remove(); + super.dispose(); + } + + private _refreshStyle(decoration: IInternalDecoration, updateAnchor?: boolean): void { + if (updateAnchor) { + if (decoration.options.anchor === 'right') { + this._canvas.style.right = decoration.options.x ? `${decoration.options.x * this._renderService.dimensions.actualCellWidth}px` : ''; + } else { + this._canvas.style.left = decoration.options.x ? `${decoration.options.x * this._renderService.dimensions.actualCellWidth}px` : ''; + } + } + if (!decoration.options.overviewRulerOptions) { + this._decorationElements.delete(decoration); + return; + } + this._ctx.lineWidth = !decoration.options.overviewRulerOptions.position ? 2 : 6; + this._ctx.strokeStyle = decoration.options.overviewRulerOptions.color; + this._ctx.strokeRect( + !decoration.options.overviewRulerOptions.position || decoration.options.overviewRulerOptions.position === 'left' ? 0 : decoration.options.overviewRulerOptions.position === 'right' ? renderSizes[SizeIndex.OUTER_SIZE] + renderSizes[SizeIndex.INNER_SIZE]: renderSizes[SizeIndex.OUTER_SIZE], + Math.round(this._canvas.height * (decoration.options.marker.line / this._bufferService.buffers.active.lines.length)), + !decoration.options.overviewRulerOptions.position ? this._width : decoration.options.overviewRulerOptions.position === 'center' ? renderSizes[SizeIndex.INNER_SIZE]: renderSizes[SizeIndex.OUTER_SIZE], + window.devicePixelRatio + ); + } + + private _refreshDecorations(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void { + if (updateCanvasDimensions) { + this._canvas.style.width = `${this._width}px`; + this._canvas.style.height = `${this._screenElement.clientHeight}px`; + this._canvas.width = Math.floor((this._width)* window.devicePixelRatio); + this._canvas.height = Math.floor(this._screenElement.clientHeight * window.devicePixelRatio); + } + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + for (const decoration of this._decorationService.decorations) { + this._renderDecoration(decoration, updateAnchor); + } + } + + private _renderDecoration(decoration: IInternalDecoration, updateAnchor?: boolean): void { + const element = this._decorationElements.get(decoration); + if (!element) { + this._decorationElements.set(decoration, this._canvas); + } + this._refreshStyle(decoration, updateAnchor); + decoration.onRenderEmitter.fire(this._canvas); + } + + private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void { + if (this._animationFrame !== undefined) { + return; + } + this._animationFrame = window.requestAnimationFrame(() => { + this._refreshDecorations(updateCanvasDimensions, updateAnchor); + this._animationFrame = undefined; + }); + } + + private _removeDecoration(decoration: IInternalDecoration): void { + this._decorationElements.get(decoration)?.remove(); + this._decorationElements.delete(decoration); + } +} diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 08963933..31ef1287 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -45,7 +45,7 @@ import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; -import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService, IDecorationService } from 'browser/services/Services'; +import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; import { IBuffer } from 'common/buffer/Types'; import { MouseService } from 'browser/services/MouseService'; @@ -55,7 +55,10 @@ import { CoreTerminal } from 'common/CoreTerminal'; import { color, rgba } from 'browser/Color'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { toRgbString } from 'common/input/XParseColor'; -import { DecorationService } from 'browser/services/DecorationService'; +import { BufferDecorationRenderer } from 'browser/Decorations/BufferDecorationRenderer'; +import { OverviewRulerRenderer } from 'browser/Decorations/OverviewRulerRenderer'; +import { DecorationService } from 'common/services/DecorationService'; +import { IDecorationService } from 'common/services/Services'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; @@ -71,6 +74,8 @@ export class Terminal extends CoreTerminal implements ITerminal { private _helperContainer: HTMLElement | undefined; private _compositionView: HTMLElement | undefined; + private _overviewRulerRenderer: OverviewRulerRenderer | undefined; + // private _visualBellTimer: number; public browser: IBrowser = Browser as any; @@ -78,6 +83,7 @@ export class Terminal extends CoreTerminal implements ITerminal { private _customKeyEventHandler: CustomKeyEventHandler | undefined; // browser services + private _decorationService: DecorationService; private _charSizeService: ICharSizeService | undefined; private _mouseService: IMouseService | undefined; private _renderService: IRenderService | undefined; @@ -109,7 +115,6 @@ export class Terminal extends CoreTerminal implements ITerminal { public linkifier: ILinkifier; public linkifier2: ILinkifier2; public viewport: IViewport | undefined; - public decorationService: IDecorationService; private _compositionHelper: ICompositionHelper | undefined; private _mouseZoneManager: IMouseZoneManager | undefined; private _accessibilityManager: AccessibilityManager | undefined; @@ -159,7 +164,8 @@ export class Terminal extends CoreTerminal implements ITerminal { this.linkifier = this._instantiationService.createInstance(Linkifier); this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2)); - this.decorationService = this.register(this._instantiationService.createInstance(DecorationService)); + this._decorationService = this._instantiationService.createInstance(DecorationService); + this._instantiationService.setService(IDecorationService, this._decorationService); // Setup InputHandler listeners this.register(this._inputHandler.onRequestBell(() => this.bell())); @@ -471,6 +477,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this._viewportElement = document.createElement('div'); this._viewportElement.classList.add('xterm-viewport'); fragment.appendChild(this._viewportElement); + this._viewportScrollArea = document.createElement('div'); this._viewportScrollArea.classList.add('xterm-scroll-area'); this._viewportElement.appendChild(this._viewportScrollArea); @@ -576,8 +583,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this.onScroll(() => this._mouseZoneManager!.clearAll())); this.linkifier.attachToDom(this.element, this._mouseZoneManager); this.linkifier2.attachToDom(this.screenElement, this._mouseService, this._renderService); - - this.decorationService.attachToDom(this.screenElement, this._renderService, this._bufferService); + this.register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement)); // This event listener must be registered aftre MouseZoneManager is created this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.onMouseDown(e))); @@ -595,6 +601,13 @@ export class Terminal extends CoreTerminal implements ITerminal { this._accessibilityManager = new AccessibilityManager(this, this._renderService); } + if (this.options.overviewRulerWidth) { + this._overviewRulerRenderer = this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement); + } + this.optionsService.onOptionChange(() => { + if (!this._overviewRulerRenderer && this.options.overviewRulerWidth && this._viewportElement && this.screenElement) { + this._overviewRulerRenderer = this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement); + }}); // Measure the character size this._charSizeService.measure(); @@ -1003,7 +1016,7 @@ export class Terminal extends CoreTerminal implements ITerminal { } public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { - return this.decorationService!.registerDecoration(decorationOptions); + return this._decorationService.registerDecoration(decorationOptions); } /** diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 35b52d62..8860bb41 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -9,6 +9,7 @@ import { ICoreTerminal, CharData, ITerminalOptions } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; import { IBuffer } from 'common/buffer/Types'; import { IFunctionIdentifier, IParams } from 'common/parser/Types'; +import { createDecorator } from 'common/services/ServiceRegistry'; export interface ITerminal extends IPublicTerminal, ICoreTerminal { element: HTMLElement | undefined; diff --git a/src/browser/services/DecorationService.ts b/src/browser/services/DecorationService.ts deleted file mode 100644 index ed3c7224..00000000 --- a/src/browser/services/DecorationService.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Copyright (c) 2022 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IDecorationService, IRenderService } from 'browser/services/Services'; -import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { Disposable } from 'common/Lifecycle'; -import { IBufferService, IInstantiationService } from 'common/services/Services'; -import { IDecorationOptions, IDecoration, IMarker } from 'xterm'; - -export class DecorationService extends Disposable implements IDecorationService { - - private readonly _decorations: Decoration[] = []; - private _container: HTMLElement | undefined; - private _screenElement: HTMLElement | undefined; - private _renderService: IRenderService | undefined; - private _animationFrame: number | undefined; - - constructor(@IInstantiationService private readonly _instantiationService: IInstantiationService) { super(); } - - public attachToDom(screenElement: HTMLElement, renderService: IRenderService): void { - this._renderService = renderService; - this._screenElement = screenElement; - this._container = document.createElement('div'); - this._container.classList.add('xterm-decoration-container'); - screenElement.appendChild(this._container); - this.register(this._renderService.onRenderedBufferChange(() => this.refresh())); - this.register(this._renderService.onDimensionsChange(() => this.refresh(true))); - } - - public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { - if (decorationOptions.marker.isDisposed || !this._container) { - return undefined; - } - const decoration = this._instantiationService.createInstance(Decoration, decorationOptions, this._container); - this._decorations.push(decoration); - decoration.onDispose(() => this._decorations.splice(this._decorations.indexOf(decoration), 1)); - this._queueRefresh(); - return decoration; - } - - private _queueRefresh(): void { - if (this._animationFrame !== undefined) { - return; - } - this._animationFrame = window.requestAnimationFrame(() => { - this.refresh(); - this._animationFrame = undefined; - }); - } - - public refresh(shouldRecreate?: boolean): void { - if (!this._renderService) { - return; - } - for (const decoration of this._decorations) { - decoration.render(this._renderService, shouldRecreate); - } - } - - public dispose(): void { - for (const decoration of this._decorations) { - decoration.dispose(); - } - if (this._screenElement && this._container && this._screenElement.contains(this._container)) { - this._screenElement.removeChild(this._container); - } - } -} -export class Decoration extends Disposable implements IDecoration { - private readonly _marker: IMarker; - private _element: HTMLElement | undefined; - - public isDisposed: boolean = false; - - public get element(): HTMLElement | undefined { return this._element; } - public get marker(): IMarker { return this._marker; } - - private _onDispose = new EventEmitter(); - public get onDispose(): IEvent { return this._onDispose.event; } - - private _onRender = new EventEmitter(); - public get onRender(): IEvent { return this._onRender.event; } - - public x: number; - public anchor: 'left' | 'right'; - public width: number; - public height: number; - - constructor( - options: IDecorationOptions, - private readonly _container: HTMLElement, - @IBufferService private readonly _bufferService: IBufferService - ) { - super(); - this.x = options.x ?? 0; - this._marker = options.marker; - this._marker.onDispose(() => this.dispose()); - this.anchor = options.anchor || 'left'; - this.width = options.width || 1; - this.height = options.height || 1; - } - - public render(renderService: IRenderService, shouldRecreate?: boolean): void { - if (!this._element || shouldRecreate) { - this._createElement(renderService, shouldRecreate); - } - if (this._container && this._element && !this._container.contains(this._element)) { - this._container.append(this._element); - } - this._refreshStyle(renderService); - if (this._element) { - this._onRender.fire(this._element); - } - } - - private _createElement(renderService: IRenderService, shouldRecreate?: boolean): void { - if (shouldRecreate && this._element && this._container.contains(this._element)) { - this._container.removeChild(this._element); - } - this._element = document.createElement('div'); - this._element.classList.add('xterm-decoration'); - this._element.style.width = `${this.width * renderService.dimensions.actualCellWidth}px`; - this._element.style.height = `${this.height * renderService.dimensions.actualCellHeight}px`; - this._element.style.top = `${(this.marker.line - this._bufferService.buffers.active.ydisp) * renderService.dimensions.actualCellHeight}px`; - this._element.style.lineHeight = `${renderService.dimensions.actualCellHeight}px`; - - if (this.x && this.x > this._bufferService.cols) { - // exceeded the container width, so hide - this._element.style.display = 'none'; - } - if (this.anchor === 'right') { - this._element.style.right = this.x ? `${this.x * renderService.dimensions.actualCellWidth}px` : ''; - } else { - this._element.style.left = this.x ? `${this.x * renderService.dimensions.actualCellWidth}px` : ''; - } - } - - private _refreshStyle(renderService: IRenderService): void { - if (!this._element) { - return; - } - const line = this.marker.line - this._bufferService.buffers.active.ydisp; - if (line < 0 || line > this._bufferService.rows) { - // outside of viewport - this._element.style.display = 'none'; - } else { - this._element.style.top = `${line * renderService.dimensions.actualCellHeight}px`; - this._element.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; - } - } - - public override dispose(): void { - if (this.isDisposed) { - return; - } - if (this._element && this._container.contains(this._element)) { - this._container.removeChild(this._element); - } - this.isDisposed = true; - this._onDispose.fire(); - } -} diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 7faf3f0f..1598ef02 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -115,11 +115,3 @@ export interface ICharacterJoinerService { deregister(joinerId: number): boolean; getJoinedCharacters(row: number): [number, number][]; } - - -export const IDecorationService = createDecorator('DecorationService'); -export interface IDecorationService extends IDisposable { - registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; - refresh(): void; - attachToDom(screenElement: HTMLElement, renderService: IRenderService, bufferService: IBufferService): void; -} diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts new file mode 100644 index 00000000..911fd369 --- /dev/null +++ b/src/common/services/DecorationService.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { EventEmitter } from 'common/EventEmitter'; +import { Disposable } from 'common/Lifecycle'; +import { IDecorationService, IInternalDecoration } from 'common/services/Services'; +import { IDecorationOptions, IDecoration, IMarker, IEvent } from 'xterm'; + +export class DecorationService extends Disposable implements IDecorationService { + public serviceBrand: any; + + private readonly _decorations: IInternalDecoration[] = []; + + private _onDecorationRegistered = this.register(new EventEmitter()); + public get onDecorationRegistered(): IEvent { return this._onDecorationRegistered.event; } + private _onDecorationRemoved = this.register(new EventEmitter()); + public get onDecorationRemoved(): IEvent { return this._onDecorationRemoved.event; } + + public get decorations(): IterableIterator { return this._decorations.values(); } + + constructor() { + super(); + } + + public registerDecoration(options: IDecorationOptions): IDecoration | undefined { + if (options.marker.isDisposed) { + return undefined; + } + const decoration = new Decoration(options); + if (decoration) { + decoration.onDispose(() => { + if (decoration) { + this._decorations.splice(this._decorations.indexOf(decoration), 1); + } + }); + this._decorations.push(decoration); + this._onDecorationRegistered.fire(decoration); + } + return decoration; + } + + public dispose(): void { + for (const decoration of this._decorations) { + this._onDecorationRemoved.fire(decoration); + decoration.dispose(); + } + this._decorations.length = 0; + } +} + +class Decoration extends Disposable implements IInternalDecoration { + public readonly marker: IMarker; + public element: HTMLElement | undefined; + public isDisposed: boolean = false; + + public readonly onRenderEmitter = this.register(new EventEmitter()); + public readonly onRender = this.onRenderEmitter.event; + private _onDispose = this.register(new EventEmitter()); + public readonly onDispose = this._onDispose.event; + + constructor( + public readonly options: IDecorationOptions + ) { + super(); + this.marker = options.marker; + } + public override dispose(): void { + this._onDispose.fire(); + super.dispose(); + } +} diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 43fe9981..4008a431 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -52,7 +52,8 @@ export const DEFAULT_OPTIONS: Readonly = { altClickMovesCursor: true, convertEol: false, termName: 'xterm', - cancelEvents: false + cancelEvents: false, + overviewRulerWidth: undefined }; const FONT_WEIGHT_OPTIONS: Extract[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 90dca988..876d90bc 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -3,10 +3,11 @@ * @license MIT */ -import { IEvent } from 'common/EventEmitter'; +import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; +import { IDecorationOptions, IDecoration } from 'xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -245,6 +246,7 @@ export interface ITerminalOptions { windowsMode: boolean; windowOptions: IWindowOptions; wordSeparator: string; + overviewRulerWidth?: number; [key: string]: any; cancelEvents: boolean; @@ -298,3 +300,16 @@ export interface IUnicodeVersionProvider { readonly version: string; wcwidth(ucs: number): 0 | 1 | 2; } + +export const IDecorationService = createDecorator('DecorationService'); +export interface IDecorationService extends IDisposable { + serviceBrand: undefined; + readonly decorations: IterableIterator; + readonly onDecorationRegistered: IEvent; + readonly onDecorationRemoved: IEvent; + registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; +} +export interface IInternalDecoration extends IDecoration { + readonly options: IDecorationOptions; + readonly onRenderEmitter: IEventEmitter; +} diff --git a/src/tsconfig-library-base.json b/src/tsconfig-library-base.json index e08695d7..00cecc06 100644 --- a/src/tsconfig-library-base.json +++ b/src/tsconfig-library-base.json @@ -4,6 +4,7 @@ "composite": true, "strict": true, "declarationMap": true, - "experimentalDecorators": true + "experimentalDecorators": true, + "downlevelIteration": true } } diff --git a/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index ee6a0cfa..43f1e97d 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -574,7 +574,7 @@ describe('API Integration Tests', function(): void { await page.evaluate(`window.term.scrollLines(10)`); await page.evaluate(`window.term.addMarker(3)`); await page.evaluate(`window.term.addMarker(4)`); - await page.evaluate(` + await page.evaluate(` for (let i = 0; i < window.term.markers.length; ++i) { const marker = window.term.markers[i]; marker.onDispose(() => window.disposeStack.push(marker)); @@ -732,48 +732,60 @@ describe('API Integration Tests', function(): void { }); describe('registerDecoration', () => { - it('should register decorations and render them', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker1 = window.term.addMarker(1)`); - await page.evaluate(`window.marker2 = window.term.addMarker(2)`); - await page.evaluate(`window.term.registerDecoration({ marker: window.marker1 })`); - await page.evaluate(`window.term.registerDecoration({ marker: window.marker2 })`); - await page.evaluate(`window.term.resize(10, 5)`); - assert.equal(await page.evaluate(`document.querySelectorAll('.xterm-screen .xterm-decoration').length`), 2); + describe('bufferDecorations', () => { + it('should register decorations and render them when terminal open is called', async () => { + await page.evaluate(`window.term = new Terminal({})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1 })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2 })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-screen .xterm-decoration').length`, 2); + }); + it('should return undefined when the marker has already been disposed of', async () => { + await openTerminal(page); + await page.evaluate(`window.marker = window.term.addMarker(1)`); + await page.evaluate(`window.marker.dispose()`); + await pollFor(page, `window.decoration = window.term.registerDecoration({ marker: window.marker });`, undefined); + }); + it('should throw when a negative x offset is provided', async () => { + await openTerminal(page); + await page.evaluate(`window.marker = window.term.addMarker(1)`); + await page.evaluate(` + try { + window.decoration = window.term.registerDecoration({ marker: window.marker, x: -2 }); + } catch (e) { + window.throwMessage = e.message; + } + `); + await pollFor(page, 'window.throwMessage', 'This API only accepts positive integers'); + }); }); - it('on resize should dispose of the old decoration and create a new one', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(`window.decoration = window.term.registerDecoration({ marker: window.marker })`); - await page.evaluate(`window.term.resize(10, 5)`); - assert.equal(await page.evaluate(`document.querySelectorAll('.xterm-screen .xterm-decoration').length`), 1); - }); - it('should return undefined when the marker has already been disposed of', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(`window.marker.dispose()`); - assert.equal(await page.evaluate(`window.decoration = window.term.registerDecoration({ marker: window.marker });`), undefined); - }); - it('should throw when a negative x offset is provided', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(` - try { - window.decoration = window.term.registerDecoration({ marker: window.marker, x: -2 }); - } catch (e) { - window.throwMessage = e.message; - } - `); - await pollFor(page, 'window.throwMessage', 'This API only accepts positive integers'); + describe('overviewRulerDecorations', () => { + it('should not add an overview ruler when width is not set', async () => { + await page.evaluate(`window.term = new Terminal({})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red' } })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2, overviewRulerOptions: { color: 'blue' } })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 0); + }); + it('should add an overview ruler when width is set', async () => { + await page.evaluate(`window.term = new Terminal({ overviewRulerWidth: 15 })`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red' } })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2, overviewRulerOptions: { color: 'blue' } })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 1); + }); }); }); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 2cd4daa6..24ba940e 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -266,6 +266,12 @@ declare module 'xterm' { * All features are disabled by default for security reasons. */ windowOptions?: IWindowOptions; + + /** + * The width, in pixels, of the canvas for the overview ruler. The overview + * ruler will be hidden when not set. + */ + overviewRulerWidth?: number; } /** @@ -394,7 +400,7 @@ declare module 'xterm' { } /** - * Represents a disposable with an + * Represents a disposable that tracks is disposed state. * @param onDispose event listener and * @param isDisposed property. */ @@ -427,49 +433,53 @@ declare module 'xterm' { readonly onRender: IEvent; /** - * The HTMLElement that gets created after the - * first _onRender call, or undefined if accessed before + * The element that the decoration is rendered to. This will be undefined + * until it is rendered for the first time by @{link IDecoration.onRender}. * that. */ - readonly element: HTMLElement | undefined; + element: HTMLElement | undefined; } - /** - * Options provided when registering a decoration - * containing a @param marker, @param anchor, - * @param x offset from the anchor, @param width in cells - * and @param height in cells. + /* + * Options that define the presentation of the decoration. */ export interface IDecorationOptions { /** * The line in the terminal where * the decoration will be displayed */ - marker: IMarker; + readonly marker: IMarker; /* * Where the decoration will be anchored - * defaults to the left edge */ - anchor?: 'right' | 'left'; + readonly anchor?: 'right' | 'left'; /** * The x position offset relative to the anchor - */ - x?: number; + */ + readonly x?: number; /** - * The width of the decoration in cells, which defaults to - * cell width + * The width of the decoration in cells, defaults to 1. */ - width?: number; + readonly width?: number; /** - * The height of the decoration in cells, which defaults to - * cell height + * The height of the decoration in cells, defaults to 1. */ - height?: number; + readonly height?: number; + + /** + * When defined, renders the decoration in the overview ruler to the right + * of the terminal. {@link ITerminalOptions.overviewRulerWidth} must be set + * in order to see the overview ruler. + * @param color The color of the decoration. + * @param position The position of the decoration. + */ + readonly overviewRulerOptions?: { color: string; position?: 'left' | 'center' | 'right'} } /** @@ -939,7 +949,7 @@ declare module 'xterm' { /** * (EXPERIMENTAL) Adds a decoration to the terminal using - * @param decorationOptions, which takes a marker and an optional anchor, + * @param decorationOptions, which takes a marker and an optional anchor, * width, height, and x offset from the anchor. Returns the decoration or * undefined if the alt buffer is active or the marker has already been disposed of. * @throws when options include a negative x offset.