Skip to content

Commit 71bfbe4

Browse files
authored
Merge pull request #3630 from meganrogge/merogge/buffer-marker-decoration
add Decorations
2 parents a13f11a + 9ed9520 commit 71bfbe4

File tree

12 files changed

+349
-17
lines changed

12 files changed

+349
-17
lines changed

css/xterm.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,8 @@
173173
.xterm-strikethrough {
174174
text-decoration: line-through;
175175
}
176+
177+
.xterm-screen .xterm-decoration-container .xterm-decoration {
178+
z-index: 6;
179+
position: absolute;
180+
}

demo/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ if (document.location.pathname === '/test') {
149149
document.getElementById('serialize').addEventListener('click', serializeButtonHandler);
150150
document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler);
151151
document.getElementById('load-test').addEventListener('click', loadTest);
152+
document.getElementById('add-decoration').addEventListener('click', addDecoration);
152153
}
153154

154155
function createTerminal(): void {
@@ -525,3 +526,12 @@ function loadTest() {
525526
term._core._onData.fire('\x03');
526527
});
527528
}
529+
530+
function addDecoration() {
531+
const marker = term.addMarker(1);
532+
const decoration = term.registerDecoration({ marker });
533+
term.write('');
534+
decoration.onRender(() => {
535+
decoration.element.style.backgroundColor = 'red';
536+
});
537+
}

demo/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ <h3>Test</h3>
6464
<button id="dispose" title="This is used to testing memory leaks">Dispose terminal</button>
6565
<button id="custom-glyph" title="Write custom box drawing and block element characters to the terminal">Test custom glyphs</button>
6666
<button id="load-test" title="Write several MB of data to simulate a lot of data coming from the process">Load test</button>
67+
<button id="add-decoration" title="Add a decoration to the terminal">Decoration</button>
6768
</div>
6869
</div>
6970
</div>

src/browser/Terminal.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ import * as Strings from 'browser/LocalizableStrings';
3737
import { SoundService } from 'browser/services/SoundService';
3838
import { MouseZoneManager } from 'browser/MouseZoneManager';
3939
import { AccessibilityManager } from './AccessibilityManager';
40-
import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm';
40+
import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
4141
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
4242
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
4343
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
4444
import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter';
4545
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
4646
import { ColorManager } from 'browser/ColorManager';
4747
import { RenderService } from 'browser/services/RenderService';
48-
import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService } from 'browser/services/Services';
48+
import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService, IDecorationService } from 'browser/services/Services';
4949
import { CharSizeService } from 'browser/services/CharSizeService';
5050
import { IBuffer } from 'common/buffer/Types';
5151
import { MouseService } from 'browser/services/MouseService';
@@ -55,6 +55,7 @@ import { CoreTerminal } from 'common/CoreTerminal';
5555
import { color, rgba } from 'browser/Color';
5656
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
5757
import { toRgbString } from 'common/input/XParseColor';
58+
import { DecorationService } from 'browser/services/DecorationService';
5859

5960
// Let it work inside Node.js for automated testing purposes.
6061
const document: Document = (typeof window !== 'undefined') ? window.document : null as any;
@@ -108,6 +109,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
108109
public linkifier: ILinkifier;
109110
public linkifier2: ILinkifier2;
110111
public viewport: IViewport | undefined;
112+
public decorationService: IDecorationService;
111113
private _compositionHelper: ICompositionHelper | undefined;
112114
private _mouseZoneManager: IMouseZoneManager | undefined;
113115
private _accessibilityManager: AccessibilityManager | undefined;
@@ -157,6 +159,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
157159

158160
this.linkifier = this._instantiationService.createInstance(Linkifier);
159161
this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2));
162+
this.decorationService = this.register(this._instantiationService.createInstance(DecorationService));
160163

161164
// Setup InputHandler listeners
162165
this.register(this._inputHandler.onRequestBell(() => this.bell()));
@@ -574,6 +577,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
574577
this.linkifier.attachToDom(this.element, this._mouseZoneManager);
575578
this.linkifier2.attachToDom(this.screenElement, this._mouseService, this._renderService);
576579

580+
this.decorationService.attachToDom(this.screenElement, this._renderService, this._bufferService);
577581
// This event listener must be registered aftre MouseZoneManager is created
578582
this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.onMouseDown(e)));
579583

@@ -998,6 +1002,10 @@ export class Terminal extends CoreTerminal implements ITerminal {
9981002
return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset);
9991003
}
10001004

1005+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
1006+
return this.decorationService!.registerDecoration(decorationOptions);
1007+
}
1008+
10011009
/**
10021010
* Gets whether the terminal has an active selection.
10031011
*/

src/browser/TestUtils.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { IDisposable, IMarker, ISelectionPosition, ILinkProvider } from 'xterm';
6+
import { IDisposable, IMarker, ISelectionPosition, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
77
import { IEvent, EventEmitter } from 'common/EventEmitter';
88
import { ICharacterJoinerService, ICharSizeService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
99
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/Types';
@@ -102,6 +102,9 @@ export class MockTerminal implements ITerminal {
102102
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
103103
throw new Error('Method not implemented.');
104104
}
105+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
106+
throw new Error('Method not implemented.');
107+
}
105108
public hasSelection(): boolean {
106109
throw new Error('Method not implemented.');
107110
}
@@ -283,6 +286,9 @@ export class MockRenderer implements IRenderer {
283286
public setColors(colors: IColorSet): void {
284287
throw new Error('Method not implemented.');
285288
}
289+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration {
290+
throw new Error('Method not implemented.');
291+
}
286292
public onResize(cols: number, rows: number): void { }
287293
public onCharSizeChanged(): void { }
288294
public onBlur(): void { }
@@ -422,6 +428,9 @@ export class MockRenderService implements IRenderService {
422428
public dispose(): void {
423429
throw new Error('Method not implemented.');
424430
}
431+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration {
432+
throw new Error('Method not implemented.');
433+
}
425434
}
426435

427436
export class MockCharacterJoinerService implements ICharacterJoinerService {

src/browser/Types.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
* @license MIT
44
*/
55

6-
import { IDisposable, IMarker, ISelectionPosition } from 'xterm';
6+
import { IDecorationOptions, IDecoration, IDisposable, IMarker, ISelectionPosition } from 'xterm';
77
import { IEvent } from 'common/EventEmitter';
88
import { ICoreTerminal, CharData, ITerminalOptions } from 'common/Types';
99
import { IMouseService, IRenderService } from './services/Services';
10-
import { IBuffer, IBufferSet } from 'common/buffer/Types';
10+
import { IBuffer } from 'common/buffer/Types';
1111
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
1212

1313
export interface ITerminal extends IPublicTerminal, ICoreTerminal {
@@ -61,6 +61,7 @@ export interface IPublicTerminal extends IDisposable {
6161
registerCharacterJoiner(handler: (text: string) => [number, number][]): number;
6262
deregisterCharacterJoiner(joinerId: number): void;
6363
addMarker(cursorYOffset: number): IMarker | undefined;
64+
registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined;
6465
hasSelection(): boolean;
6566
getSelection(): string;
6667
getSelectionPosition(): ISelectionPosition | undefined;

src/browser/public/Terminal.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { Terminal as ITerminalApi, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, FontWeight, IModes } from 'xterm';
6+
import { Terminal as ITerminalApi, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBufferNamespace as IBufferNamespaceApi, IParser, ILinkProvider, IUnicodeHandling, FontWeight, IModes, IDecorationOptions, IDecoration } from 'xterm';
77
import { ITerminal } from 'browser/Types';
88
import { Terminal as TerminalCore } from 'browser/Terminal';
99
import * as Strings from 'browser/LocalizableStrings';
@@ -171,6 +171,11 @@ export class Terminal implements ITerminalApi {
171171
this._verifyIntegers(cursorYOffset);
172172
return this._core.addMarker(cursorYOffset);
173173
}
174+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
175+
this._checkProposedApi();
176+
this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0);
177+
return this._core.registerDecoration(decorationOptions);
178+
}
174179
public addMarker(cursorYOffset: number): IMarker | undefined {
175180
return this.registerMarker(cursorYOffset);
176181
}
@@ -281,4 +286,12 @@ export class Terminal implements ITerminalApi {
281286
}
282287
}
283288
}
289+
290+
private _verifyPositiveIntegers(...values: number[]): void {
291+
for (const value of values) {
292+
if (value && (value === Infinity || isNaN(value) || value % 1 !== 0 || value < 0)) {
293+
throw new Error('This API only accepts positive integers');
294+
}
295+
}
296+
}
284297
}

src/browser/renderer/Renderer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { IRenderLayer, IRenderer, IRenderDimensions, IRequestRedrawEvent } from
1010
import { LinkRenderLayer } from 'browser/renderer/LinkRenderLayer';
1111
import { Disposable } from 'common/Lifecycle';
1212
import { IColorSet, ILinkifier, ILinkifier2 } from 'browser/Types';
13-
import { ICharSizeService, ICoreBrowserService } from 'browser/services/Services';
14-
import { IBufferService, IOptionsService, ICoreService, IInstantiationService } from 'common/services/Services';
13+
import { ICharSizeService } from 'browser/services/Services';
14+
import { IBufferService, IOptionsService, IInstantiationService } from 'common/services/Services';
1515
import { removeTerminalFromCache } from 'browser/renderer/atlas/CharAtlasCache';
1616
import { EventEmitter, IEvent } from 'common/EventEmitter';
17+
import { IDecorationOptions, IDecoration } from 'xterm';
1718

1819
let nextRendererId = 1;
1920

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { IDecorationService, IRenderService } from 'browser/services/Services';
7+
import { EventEmitter, IEvent } from 'common/EventEmitter';
8+
import { Disposable } from 'common/Lifecycle';
9+
import { IBufferService, IInstantiationService } from 'common/services/Services';
10+
import { IDecorationOptions, IDecoration, IMarker } from 'xterm';
11+
12+
export class DecorationService extends Disposable implements IDecorationService {
13+
14+
private readonly _decorations: Decoration[] = [];
15+
private _container: HTMLElement | undefined;
16+
private _screenElement: HTMLElement | undefined;
17+
private _renderService: IRenderService | undefined;
18+
19+
constructor(
20+
@IBufferService private readonly _bufferService: IBufferService,
21+
@IInstantiationService private readonly _instantiationService: IInstantiationService) {
22+
super();
23+
}
24+
25+
public attachToDom(screenElement: HTMLElement, renderService: IRenderService): void {
26+
this._renderService = renderService;
27+
this._screenElement = screenElement;
28+
this._container = document.createElement('div');
29+
this._container.classList.add('xterm-decoration-container');
30+
screenElement.appendChild(this._container);
31+
this.refresh();
32+
this.register(this._renderService.onRenderedBufferChange(() => this.refresh()));
33+
this.register(this._renderService.onDimensionsChange(() => this.refresh(true)));
34+
}
35+
36+
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
37+
if (decorationOptions.marker.isDisposed || !this._container) {
38+
return undefined;
39+
}
40+
const decoration = this._instantiationService.createInstance(Decoration, decorationOptions, this._container);
41+
this._decorations.push(decoration);
42+
decoration.onDispose(() => this._decorations.splice(this._decorations.indexOf(decoration), 1));
43+
return decoration;
44+
}
45+
46+
public refresh(recreate?: boolean): void {
47+
if (!this._bufferService || !this._renderService) {
48+
return;
49+
}
50+
for (const decoration of this._decorations) {
51+
decoration.render(this._renderService, recreate);
52+
}
53+
}
54+
55+
public dispose(): void {
56+
for (const decoration of this._decorations) {
57+
decoration.dispose();
58+
}
59+
if (this._container) {
60+
this._screenElement?.removeChild(this._container);
61+
}
62+
}
63+
}
64+
export class Decoration extends Disposable implements IDecoration {
65+
private static _nextId = 1;
66+
private readonly _marker: IMarker;
67+
private _element: HTMLElement | undefined;
68+
private readonly _id: number = Decoration._nextId++;
69+
public isDisposed: boolean = false;
70+
71+
public get element(): HTMLElement | undefined { return this._element; }
72+
public get marker(): IMarker { return this._marker; }
73+
74+
private _onDispose = new EventEmitter<void>();
75+
public get onDispose(): IEvent<void> { return this._onDispose.event; }
76+
77+
private _onRender = new EventEmitter<HTMLElement>();
78+
public get onRender(): IEvent<HTMLElement> { return this._onRender.event; }
79+
80+
public x: number;
81+
public anchor: 'left' | 'right';
82+
public width: number;
83+
public height: number;
84+
85+
constructor(
86+
options: IDecorationOptions,
87+
private readonly _container: HTMLElement,
88+
@IBufferService private readonly _bufferService: IBufferService
89+
) {
90+
super();
91+
this.x = options.x ?? 0;
92+
this._marker = options.marker;
93+
this.anchor = options.anchor || 'left';
94+
this.width = options.width || 1;
95+
this.height = options.height || 1;
96+
}
97+
98+
public render(renderService: IRenderService, recreate?: boolean): void {
99+
if (!this._element || recreate) {
100+
this._createElement(renderService, recreate);
101+
}
102+
if (this._container && this._element && !this._container.contains(this._element)) {
103+
this._container.append(this._element);
104+
}
105+
this._refreshStyle(renderService);
106+
this._onRender.fire(this._element!);
107+
}
108+
109+
private _createElement(renderService: IRenderService, recreate?: boolean): void {
110+
if (recreate && this._element) {
111+
this._container.removeChild(this._element);
112+
}
113+
this._element = document.createElement('div');
114+
this._element.classList.add('xterm-decoration');
115+
this._element.style.width = `${this.width * renderService.dimensions.scaledCellWidth}px`;
116+
this._element.style.height = `${this.height * renderService.dimensions.scaledCellHeight}px`;
117+
this._element.style.top = `${(this.marker.line - this._bufferService.buffers.active.ydisp) * renderService.dimensions.scaledCellHeight}px`;
118+
119+
if (this.x && this.x > this._bufferService.cols) {
120+
this._element!.style.display = 'none';
121+
}
122+
if (this.anchor === 'right') {
123+
this._element.style.right = this.x ? `${this.x * renderService.dimensions.scaledCellWidth}px` : '';
124+
} else {
125+
this._element.style.left = this.x ? `${this.x * renderService.dimensions.scaledCellWidth}px` : '';
126+
}
127+
this.register({
128+
dispose: () => {
129+
if (this.isDisposed) {
130+
return;
131+
}
132+
this._container.removeChild(this._element!);
133+
this.isDisposed = true;
134+
this._marker.dispose();
135+
// Emit before super.dispose such that dispose listeners get a change to react
136+
this._onDispose.fire();
137+
super.dispose();
138+
}
139+
});
140+
}
141+
142+
private _refreshStyle(renderService: IRenderService): void {
143+
const line = this.marker.line - this._bufferService.buffers.active.ydisp;
144+
if (line < 0 || line > this._bufferService.rows) {
145+
// outside of viewport
146+
this._element!.style.display = 'none';
147+
} else {
148+
this._element!.style.top = `${line * renderService.dimensions.scaledCellHeight}px`;
149+
this._element!.style.display = 'block';
150+
}
151+
}
152+
}

src/browser/services/Services.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { IColorSet } from 'browser/Types';
99
import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
1010
import { createDecorator } from 'common/services/ServiceRegistry';
1111
import { IDisposable } from 'common/Types';
12+
import { IDecorationOptions, IDecoration } from 'xterm';
13+
import { IBufferService } from 'common/services/Services';
1214

1315
export const ICharSizeService = createDecorator<ICharSizeService>('CharSizeService');
1416
export interface ICharSizeService {
@@ -113,3 +115,11 @@ export interface ICharacterJoinerService {
113115
deregister(joinerId: number): boolean;
114116
getJoinedCharacters(row: number): [number, number][];
115117
}
118+
119+
120+
export const IDecorationService = createDecorator<IDecorationService>('DecorationService');
121+
export interface IDecorationService extends IDisposable {
122+
registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined;
123+
refresh(): void;
124+
attachToDom(screenElement: HTMLElement, renderService: IRenderService, bufferService: IBufferService): void;
125+
}

0 commit comments

Comments
 (0)