diff --git a/css/xterm.css b/css/xterm.css index 38e27a006c..ab3965b488 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -173,3 +173,8 @@ .xterm-strikethrough { text-decoration: line-through; } + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} diff --git a/demo/client.ts b/demo/client.ts index 58d719a182..a8283461dd 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -149,6 +149,7 @@ if (document.location.pathname === '/test') { document.getElementById('serialize').addEventListener('click', serializeButtonHandler); document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); + document.getElementById('add-decoration').addEventListener('click', addDecoration); } function createTerminal(): void { @@ -525,3 +526,12 @@ function loadTest() { term._core._onData.fire('\x03'); }); } + +function addDecoration() { + const marker = term.addMarker(1); + const decoration = term.registerDecoration({ marker }); + term.write(''); + decoration.onRender(() => { + decoration.element.style.backgroundColor = 'red'; + }); +} diff --git a/demo/index.html b/demo/index.html index 9c86783b52..aa28000b28 100644 --- a/demo/index.html +++ b/demo/index.html @@ -64,6 +64,7 @@

Test

+ diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 509bb2acf5..703c995be3 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -37,7 +37,7 @@ import * as Strings from 'browser/LocalizableStrings'; import { SoundService } from 'browser/services/SoundService'; import { MouseZoneManager } from 'browser/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; -import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm'; +import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm'; import { DomRenderer } from 'browser/renderer/dom/DomRenderer'; import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; @@ -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 } from 'browser/services/Services'; +import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService, IDecorationService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; import { IBuffer } from 'common/buffer/Types'; import { MouseService } from 'browser/services/MouseService'; @@ -55,6 +55,7 @@ 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'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; @@ -108,6 +109,7 @@ 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; @@ -157,6 +159,7 @@ 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)); // Setup InputHandler listeners this.register(this._inputHandler.onRequestBell(() => this.bell())); @@ -574,6 +577,7 @@ export class Terminal extends CoreTerminal implements ITerminal { 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 event listener must be registered aftre MouseZoneManager is created this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.onMouseDown(e))); @@ -998,6 +1002,10 @@ export class Terminal extends CoreTerminal implements ITerminal { return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset); } + public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { + return this.decorationService!.registerDecoration(decorationOptions); + } + /** * Gets whether the terminal has an active selection. */ diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index ee266e9c6b..10a1435b81 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IDisposable, IMarker, ISelectionPosition, ILinkProvider } from 'xterm'; +import { IDisposable, IMarker, ISelectionPosition, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { ICharacterJoinerService, ICharSizeService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/Types'; @@ -102,6 +102,9 @@ export class MockTerminal implements ITerminal { public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { throw new Error('Method not implemented.'); } + public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { + throw new Error('Method not implemented.'); + } public hasSelection(): boolean { throw new Error('Method not implemented.'); } @@ -283,6 +286,9 @@ export class MockRenderer implements IRenderer { public setColors(colors: IColorSet): void { throw new Error('Method not implemented.'); } + public registerDecoration(decorationOptions: IDecorationOptions): IDecoration { + throw new Error('Method not implemented.'); + } public onResize(cols: number, rows: number): void { } public onCharSizeChanged(): void { } public onBlur(): void { } @@ -422,6 +428,9 @@ export class MockRenderService implements IRenderService { public dispose(): void { throw new Error('Method not implemented.'); } + public registerDecoration(decorationOptions: IDecorationOptions): IDecoration { + throw new Error('Method not implemented.'); + } } export class MockCharacterJoinerService implements ICharacterJoinerService { diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index a61658406c..35b52d6282 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -3,11 +3,11 @@ * @license MIT */ -import { IDisposable, IMarker, ISelectionPosition } from 'xterm'; +import { IDecorationOptions, IDecoration, IDisposable, IMarker, ISelectionPosition } from 'xterm'; import { IEvent } from 'common/EventEmitter'; import { ICoreTerminal, CharData, ITerminalOptions } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; -import { IBuffer, IBufferSet } from 'common/buffer/Types'; +import { IBuffer } from 'common/buffer/Types'; import { IFunctionIdentifier, IParams } from 'common/parser/Types'; export interface ITerminal extends IPublicTerminal, ICoreTerminal { @@ -61,6 +61,7 @@ export interface IPublicTerminal extends IDisposable { registerCharacterJoiner(handler: (text: string) => [number, number][]): number; deregisterCharacterJoiner(joinerId: number): void; addMarker(cursorYOffset: number): IMarker | undefined; + registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; hasSelection(): boolean; getSelection(): string; getSelectionPosition(): ISelectionPosition | undefined; diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 117805f929..9571afa660 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal as ITerminalApi, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, FontWeight, IModes } from 'xterm'; +import { Terminal as ITerminalApi, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, FontWeight, IModes, IDecorationOptions, IDecoration } from 'xterm'; import { ITerminal } from 'browser/Types'; import { Terminal as TerminalCore } from 'browser/Terminal'; import * as Strings from 'browser/LocalizableStrings'; @@ -171,6 +171,11 @@ export class Terminal implements ITerminalApi { this._verifyIntegers(cursorYOffset); return this._core.addMarker(cursorYOffset); } + public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { + this._checkProposedApi(); + this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0); + return this._core.registerDecoration(decorationOptions); + } public addMarker(cursorYOffset: number): IMarker | undefined { return this.registerMarker(cursorYOffset); } @@ -281,4 +286,12 @@ export class Terminal implements ITerminalApi { } } } + + private _verifyPositiveIntegers(...values: number[]): void { + for (const value of values) { + if (value && (value === Infinity || isNaN(value) || value % 1 !== 0 || value < 0)) { + throw new Error('This API only accepts positive integers'); + } + } + } } diff --git a/src/browser/renderer/Renderer.ts b/src/browser/renderer/Renderer.ts index 7a64257da7..a58893b4ca 100644 --- a/src/browser/renderer/Renderer.ts +++ b/src/browser/renderer/Renderer.ts @@ -10,10 +10,11 @@ import { IRenderLayer, IRenderer, IRenderDimensions, IRequestRedrawEvent } from import { LinkRenderLayer } from 'browser/renderer/LinkRenderLayer'; import { Disposable } from 'common/Lifecycle'; import { IColorSet, ILinkifier, ILinkifier2 } from 'browser/Types'; -import { ICharSizeService, ICoreBrowserService } from 'browser/services/Services'; -import { IBufferService, IOptionsService, ICoreService, IInstantiationService } from 'common/services/Services'; +import { ICharSizeService } from 'browser/services/Services'; +import { IBufferService, IOptionsService, IInstantiationService } from 'common/services/Services'; import { removeTerminalFromCache } from 'browser/renderer/atlas/CharAtlasCache'; import { EventEmitter, IEvent } from 'common/EventEmitter'; +import { IDecorationOptions, IDecoration } from 'xterm'; let nextRendererId = 1; diff --git a/src/browser/services/DecorationService.ts b/src/browser/services/DecorationService.ts new file mode 100644 index 0000000000..f29ec7ea1d --- /dev/null +++ b/src/browser/services/DecorationService.ts @@ -0,0 +1,152 @@ +/** + * 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; + + constructor( + @IBufferService private readonly _bufferService: IBufferService, + @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.refresh(); + 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)); + return decoration; + } + + public refresh(recreate?: boolean): void { + if (!this._bufferService || !this._renderService) { + return; + } + for (const decoration of this._decorations) { + decoration.render(this._renderService, recreate); + } + } + + public dispose(): void { + for (const decoration of this._decorations) { + decoration.dispose(); + } + if (this._container) { + this._screenElement?.removeChild(this._container); + } + } +} +export class Decoration extends Disposable implements IDecoration { + private static _nextId = 1; + private readonly _marker: IMarker; + private _element: HTMLElement | undefined; + private readonly _id: number = Decoration._nextId++; + 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.anchor = options.anchor || 'left'; + this.width = options.width || 1; + this.height = options.height || 1; + } + + public render(renderService: IRenderService, recreate?: boolean): void { + if (!this._element || recreate) { + this._createElement(renderService, recreate); + } + if (this._container && this._element && !this._container.contains(this._element)) { + this._container.append(this._element); + } + this._refreshStyle(renderService); + this._onRender.fire(this._element!); + } + + private _createElement(renderService: IRenderService, recreate?: boolean): void { + if (recreate && 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.scaledCellWidth}px`; + this._element.style.height = `${this.height * renderService.dimensions.scaledCellHeight}px`; + this._element.style.top = `${(this.marker.line - this._bufferService.buffers.active.ydisp) * renderService.dimensions.scaledCellHeight}px`; + + if (this.x && this.x > this._bufferService.cols) { + this._element!.style.display = 'none'; + } + if (this.anchor === 'right') { + this._element.style.right = this.x ? `${this.x * renderService.dimensions.scaledCellWidth}px` : ''; + } else { + this._element.style.left = this.x ? `${this.x * renderService.dimensions.scaledCellWidth}px` : ''; + } + this.register({ + dispose: () => { + if (this.isDisposed) { + return; + } + this._container.removeChild(this._element!); + this.isDisposed = true; + this._marker.dispose(); + // Emit before super.dispose such that dispose listeners get a change to react + this._onDispose.fire(); + super.dispose(); + } + }); + } + + private _refreshStyle(renderService: IRenderService): void { + 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.scaledCellHeight}px`; + this._element!.style.display = 'block'; + } + } +} diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 4928fa28ec..7faf3f0f69 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -9,6 +9,8 @@ import { IColorSet } from 'browser/Types'; import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import { IDisposable } from 'common/Types'; +import { IDecorationOptions, IDecoration } from 'xterm'; +import { IBufferService } from 'common/services/Services'; export const ICharSizeService = createDecorator('CharSizeService'); export interface ICharSizeService { @@ -113,3 +115,11 @@ 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/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index 1fe7545680..ee6a0cfaaf 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -731,6 +731,52 @@ describe('API Integration Tests', function(): void { await pollFor(page, `window.term._core._renderService.dimensions.actualCellWidth > 0`, true); }); + 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); + }); + 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('registerLinkProvider', () => { it('should fire provideLinks when hovering cells', async () => { await openTerminal(page, { rendererType: 'dom' }); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 39245afe5e..17bf8d5013 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -380,29 +380,96 @@ declare module 'xterm' { * is trimmed and lines are added or removed. This is a single line that may * be part of a larger wrapped line. */ - export interface IMarker extends IDisposable { + export interface IMarker extends IDisposableWithEvent { /** * A unique identifier for this marker. */ readonly id: number; - /** - * Whether this marker is disposed. - */ - readonly isDisposed: boolean; - /** * The actual line index in the buffer at this point in time. This is set to * -1 if the marker has been disposed. */ readonly line: number; + } + /** + * Represents a disposable with an + * @param onDispose event listener and + * @param isDisposed property. + */ + export interface IDisposableWithEvent extends IDisposable { /** - * Event listener to get notified when the marker gets disposed. Automatic disposal - * might happen for a marker, that got invalidated by scrolling out or removal of - * a line from the buffer. + * Event listener to get notified when this gets disposed. */ onDispose: IEvent; + + /** + * Whether this is disposed. + */ + readonly isDisposed: boolean; + } + + /** + * Represents a decoration in the terminal that is associated with a particular marker and DOM element. + */ + export interface IDecoration extends IDisposableWithEvent { + /* + * The marker for the decoration in the terminal. + */ + readonly marker: IMarker; + + /** + * An event fired when the decoration + * is rendered, returns the dom element + * associated with the decoration. + */ + readonly onRender: IEvent; + + /** + * The HTMLElement that gets created after the + * first _onRender call, or undefined if accessed before + * that. + */ + readonly 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. + */ + export interface IDecorationOptions { + /** + * The line in the terminal where + * the decoration will be displayed + */ + marker: IMarker; + + /* + * Where the decoration will be anchored - + * defaults to the left edge + */ + anchor?: 'right' | 'left'; + + /** + * The x position offset relative to the anchor + */ + x?: number; + + + /** + * The width of the decoration in cells, which defaults to + * cell width + */ + width?: number; + + /** + * The height of the decoration in cells, which defaults to + * cell height + */ + height?: number; } /** @@ -870,6 +937,15 @@ declare module 'xterm' { */ addMarker(cursorYOffset: number): IMarker | undefined; + /** + * (EXPERIMENTAL) Adds a decoration to the terminal using + * @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. + */ + registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; + /** * Gets whether the terminal has an active selection. */