386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
|
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import type { ICircularList, IDeleteEvent, IInsertEvent } from '../CircularList';
|
|
import { MicrotaskTimer } from '../Async';
|
|
import { css } from '../Color';
|
|
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../Lifecycle';
|
|
import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from './Services';
|
|
import { SortedList } from '../SortedList';
|
|
import { IColor } from '../Types';
|
|
import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm';
|
|
import { Emitter } from '../Event';
|
|
|
|
// Work variables to avoid garbage collection
|
|
let $xmin = 0;
|
|
let $xmax = 0;
|
|
|
|
export class DecorationService extends Disposable implements IDecorationService {
|
|
public serviceBrand: any;
|
|
|
|
/**
|
|
* A list of all decorations, sorted by the marker's line value. This relies on the fact that
|
|
* while marker line values do change, they should all change by the same amount so this should
|
|
* never become out of order.
|
|
*/
|
|
private readonly _decorations: SortedList<IInternalDecoration>;
|
|
|
|
private readonly _lineCache = this._register(new DecorationLineCache());
|
|
|
|
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
|
|
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
|
|
private readonly _onDecorationRemoved = this._register(new Emitter<IInternalDecoration>());
|
|
public readonly onDecorationRemoved = this._onDecorationRemoved.event;
|
|
|
|
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
|
|
|
|
constructor(
|
|
@ILogService private readonly _logService: ILogService,
|
|
@IBufferService private readonly _bufferService: IBufferService
|
|
) {
|
|
super();
|
|
|
|
this._decorations = new SortedList(e => e?.marker.line, this._logService);
|
|
|
|
this._register(toDisposable(() => this.reset()));
|
|
this._register(this._bufferService.buffers.onBufferActivate(() => {
|
|
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
|
|
}));
|
|
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
|
|
}
|
|
|
|
public registerDecoration(options: IDecorationOptions): IDecoration | undefined {
|
|
if (options.marker.isDisposed) {
|
|
return undefined;
|
|
}
|
|
const decoration = new Decoration(options);
|
|
if (decoration) {
|
|
const markerDispose = decoration.marker.onDispose(() => decoration.dispose());
|
|
const listener = decoration.onDispose(() => {
|
|
listener.dispose();
|
|
if (decoration) {
|
|
if (this._decorations.delete(decoration)) {
|
|
this._lineCache.remove(decoration);
|
|
this._onDecorationRemoved.fire(decoration);
|
|
}
|
|
markerDispose.dispose();
|
|
}
|
|
});
|
|
this._decorations.insert(decoration);
|
|
this._lineCache.add(decoration);
|
|
this._onDecorationRegistered.fire(decoration);
|
|
}
|
|
return decoration;
|
|
}
|
|
|
|
public reset(): void {
|
|
for (const d of this._decorations.values()) {
|
|
d.dispose();
|
|
}
|
|
this._decorations.clear();
|
|
this._lineCache.clear();
|
|
}
|
|
|
|
public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator<IInternalDecoration> {
|
|
const bucket = this._lineCache.getDecorationsOnLine(line);
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
for (const d of bucket) {
|
|
$xmin = d.options.x ?? 0;
|
|
$xmax = $xmin + (d.options.width ?? 1);
|
|
if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
|
|
yield d;
|
|
}
|
|
}
|
|
}
|
|
|
|
public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void {
|
|
const bucket = this._lineCache.getDecorationsOnLine(line);
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
for (const d of bucket) {
|
|
$xmin = d.options.x ?? 0;
|
|
$xmax = $xmin + (d.options.width ?? 1);
|
|
if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
|
|
callback(d);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-logical-line index of decorations for fast cell lookup.
|
|
*
|
|
* Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots.
|
|
* Multi-line decorations appear in every line bucket they span. The index is kept aligned
|
|
* with marker.line updates via buffer line trim/insert/delete events.
|
|
*/
|
|
export class DecorationLineCache extends Disposable {
|
|
private readonly _decorationsByLine: Map<number, IInternalDecoration[]> = new Map();
|
|
private readonly _decorations = new Set<IInternalDecoration>();
|
|
private readonly _bufferLineListeners = this._register(new MutableDisposable<DisposableStore>());
|
|
private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer());
|
|
private _lineIndexSyncCallbacks: (() => void)[] = [];
|
|
|
|
public clear(): void {
|
|
this._lineIndexSyncCallbacks.length = 0;
|
|
this._lineIndexSyncTimer.cancel();
|
|
this._decorationsByLine.clear();
|
|
this._decorations.clear();
|
|
}
|
|
|
|
public add(decoration: IInternalDecoration): void {
|
|
this._decorations.add(decoration);
|
|
this._addToLineBuckets(decoration);
|
|
}
|
|
|
|
public remove(decoration: IInternalDecoration): void {
|
|
this._decorations.delete(decoration);
|
|
this._removeFromLineBuckets(decoration);
|
|
}
|
|
|
|
public getDecorationsOnLine(line: number): ReadonlyArray<IInternalDecoration> | undefined {
|
|
return this._decorationsByLine.get(line);
|
|
}
|
|
|
|
public attachToBufferLines(lines: ICircularList<unknown>): void {
|
|
const store = new DisposableStore();
|
|
this._bufferLineListeners.value = store;
|
|
store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount)));
|
|
store.add(lines.onInsert(event => this._handleBufferLinesInsert(event)));
|
|
store.add(lines.onDelete(event => this._handleBufferLinesDelete(event)));
|
|
}
|
|
|
|
private _getDecorationHeight(decoration: IInternalDecoration): number {
|
|
return decoration.options.height ?? 1;
|
|
}
|
|
|
|
private _addToLineBuckets(decoration: IInternalDecoration): void {
|
|
const start = decoration.marker.line;
|
|
if (start < 0) {
|
|
return;
|
|
}
|
|
decoration._indexedStartLine = start;
|
|
const height = this._getDecorationHeight(decoration);
|
|
for (let line = start; line < start + height; line++) {
|
|
let bucket = this._decorationsByLine.get(line);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
this._decorationsByLine.set(line, bucket);
|
|
}
|
|
bucket.push(decoration);
|
|
}
|
|
}
|
|
|
|
private _removeFromLineBuckets(decoration: IInternalDecoration): void {
|
|
const start = decoration._indexedStartLine;
|
|
const height = this._getDecorationHeight(decoration);
|
|
for (let line = start; line < start + height; line++) {
|
|
const bucket = this._decorationsByLine.get(line);
|
|
if (!bucket) {
|
|
continue;
|
|
}
|
|
const index = bucket.indexOf(decoration);
|
|
if (index !== -1) {
|
|
bucket.splice(index, 1);
|
|
}
|
|
if (bucket.length === 0) {
|
|
this._decorationsByLine.delete(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
private _reindexDecoration(decoration: IInternalDecoration): void {
|
|
this._removeFromLineBuckets(decoration);
|
|
if (!decoration.marker.isDisposed && decoration.marker.line >= 0) {
|
|
this._addToLineBuckets(decoration);
|
|
}
|
|
}
|
|
|
|
/** Re-index after marker line updates (buffer listeners may run before markers). */
|
|
private _scheduleLineIndexSync(callback: () => void): void {
|
|
this._lineIndexSyncCallbacks.push(callback);
|
|
this._lineIndexSyncTimer.set(() => {
|
|
const callbacks = this._lineIndexSyncCallbacks;
|
|
this._lineIndexSyncCallbacks = [];
|
|
for (const cb of callbacks) {
|
|
cb();
|
|
}
|
|
});
|
|
}
|
|
|
|
private _handleBufferLinesTrim(amount: number): void {
|
|
if (amount <= 0 || !this._decorationsByLine.size) {
|
|
return;
|
|
}
|
|
const newMap = new Map<number, IInternalDecoration[]>();
|
|
for (const [line, bucket] of this._decorationsByLine) {
|
|
const newLine = line - amount;
|
|
if (newLine < 0) {
|
|
continue;
|
|
}
|
|
this._mergeLineBucket(newMap, newLine, bucket);
|
|
}
|
|
this._decorationsByLine.clear();
|
|
for (const [line, bucket] of newMap) {
|
|
this._decorationsByLine.set(line, bucket);
|
|
}
|
|
for (const d of this._decorations) {
|
|
if (!d.marker.isDisposed) {
|
|
d._indexedStartLine -= amount;
|
|
}
|
|
}
|
|
}
|
|
|
|
private _handleBufferLinesInsert(event: IInsertEvent): void {
|
|
this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event));
|
|
}
|
|
|
|
private _handleBufferLinesDelete(event: IDeleteEvent): void {
|
|
this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event));
|
|
}
|
|
|
|
private _mergeLineBucket(newMap: Map<number, IInternalDecoration[]>, line: number, bucket: IInternalDecoration[]): void {
|
|
const existing = newMap.get(line);
|
|
if (existing) {
|
|
for (let i = 0, len = bucket.length; i < len; i++) {
|
|
existing.push(bucket[i]);
|
|
}
|
|
} else {
|
|
newMap.set(line, bucket.slice());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count).
|
|
* Decorations that span the insert point are re-indexed individually (rare vs single-line hits).
|
|
*/
|
|
private _applyBufferLinesInsert(event: IInsertEvent): void {
|
|
const { index, amount } = event;
|
|
const spanCrossers: IInternalDecoration[] = [];
|
|
for (const d of this._decorations) {
|
|
if (d.marker.isDisposed) {
|
|
continue;
|
|
}
|
|
const start = d._indexedStartLine;
|
|
if (start < index && start + this._getDecorationHeight(d) > index) {
|
|
spanCrossers.push(d);
|
|
this._removeFromLineBuckets(d);
|
|
}
|
|
}
|
|
const newMap = new Map<number, IInternalDecoration[]>();
|
|
for (const [line, bucket] of this._decorationsByLine) {
|
|
const newLine = line >= index ? line + amount : line;
|
|
this._mergeLineBucket(newMap, newLine, bucket);
|
|
}
|
|
this._decorationsByLine.clear();
|
|
for (const [line, bucket] of newMap) {
|
|
this._decorationsByLine.set(line, bucket);
|
|
}
|
|
for (const d of this._decorations) {
|
|
if (d.marker.isDisposed) {
|
|
continue;
|
|
}
|
|
if (d._indexedStartLine >= index) {
|
|
d._indexedStartLine = d.marker.line;
|
|
}
|
|
}
|
|
for (const d of spanCrossers) {
|
|
this._addToLineBuckets(d);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a
|
|
* multi-line decoration spans across the deleted range but survives.
|
|
*/
|
|
private _applyBufferLinesDelete(event: IDeleteEvent): void {
|
|
const deleteEnd = event.index + event.amount;
|
|
const newMap = new Map<number, IInternalDecoration[]>();
|
|
for (const [line, bucket] of this._decorationsByLine) {
|
|
if (line >= event.index && line < deleteEnd) {
|
|
continue;
|
|
}
|
|
const newLine = line >= deleteEnd ? line - event.amount : line;
|
|
this._mergeLineBucket(newMap, newLine, bucket);
|
|
}
|
|
this._decorationsByLine.clear();
|
|
for (const [line, bucket] of newMap) {
|
|
this._decorationsByLine.set(line, bucket);
|
|
}
|
|
const toReindex: IInternalDecoration[] = [];
|
|
for (const d of this._decorations) {
|
|
if (d.marker.isDisposed) {
|
|
continue;
|
|
}
|
|
const start = d._indexedStartLine;
|
|
const height = this._getDecorationHeight(d);
|
|
if (start >= deleteEnd) {
|
|
d._indexedStartLine = d.marker.line;
|
|
} else if (start < event.index && start + height > deleteEnd) {
|
|
toReindex.push(d);
|
|
}
|
|
}
|
|
for (const d of toReindex) {
|
|
this._reindexDecoration(d);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Decoration extends DisposableStore implements IInternalDecoration {
|
|
public readonly marker: IMarker;
|
|
public element: HTMLElement | undefined;
|
|
|
|
/** Start line used for line-index removal when marker.line is cleared on dispose. */
|
|
public _indexedStartLine: number;
|
|
|
|
public readonly onRenderEmitter = this.add(new Emitter<HTMLElement>());
|
|
public readonly onRender = this.onRenderEmitter.event;
|
|
private readonly _onDispose = this.add(new Emitter<void>());
|
|
public readonly onDispose = this._onDispose.event;
|
|
|
|
private _cachedBg: IColor | undefined | null = null;
|
|
public get backgroundColorRGB(): IColor | undefined {
|
|
if (this._cachedBg === null) {
|
|
if (this.options.backgroundColor) {
|
|
this._cachedBg = css.toColor(this.options.backgroundColor);
|
|
} else {
|
|
this._cachedBg = undefined;
|
|
}
|
|
}
|
|
return this._cachedBg;
|
|
}
|
|
|
|
private _cachedFg: IColor | undefined | null = null;
|
|
public get foregroundColorRGB(): IColor | undefined {
|
|
if (this._cachedFg === null) {
|
|
if (this.options.foregroundColor) {
|
|
this._cachedFg = css.toColor(this.options.foregroundColor);
|
|
} else {
|
|
this._cachedFg = undefined;
|
|
}
|
|
}
|
|
return this._cachedFg;
|
|
}
|
|
|
|
constructor(
|
|
public readonly options: IDecorationOptions
|
|
) {
|
|
super();
|
|
this.marker = options.marker;
|
|
this._indexedStartLine = options.marker.line;
|
|
if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) {
|
|
this.options.overviewRulerOptions.position = 'full';
|
|
}
|
|
}
|
|
|
|
public override dispose(): void {
|
|
this._onDispose.fire();
|
|
super.dispose();
|
|
}
|
|
}
|