diff --git a/demo/client.ts b/demo/client.ts index 7e0830a5..a3e58051 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -44,7 +44,7 @@ if ('WebAssembly' in window) { // Pulling in the module's types relies on the above, it's looks a // little weird here as we're importing "this" module -import { Terminal as TerminalType, ITerminalOptions } from '@xterm/xterm'; +import { Terminal as TerminalType, ITerminalOptions, type IDisposable } from '@xterm/xterm'; export interface IWindowWithTerminal extends Window { term: TerminalType; @@ -255,6 +255,7 @@ if (document.location.pathname === '/test') { document.getElementById('add-grapheme-clusters').addEventListener('click', addGraphemeClusters); document.getElementById('add-decoration').addEventListener('click', addDecoration); document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); + document.getElementById('decoration-stress-test').addEventListener('click', decorationStressTest); document.getElementById('weblinks-test').addEventListener('click', testWeblinks); document.getElementById('bce').addEventListener('click', coloredErase); addVtButtons(); @@ -1170,6 +1171,33 @@ function addOverviewRuler(): void { term.registerDecoration({ marker: term.registerMarker(10), overviewRulerOptions: { color: '#ffffff80', position: 'full' } }); } +let decorationStressTestDecorations: IDisposable[] | undefined; +function decorationStressTest(): void { + if (decorationStressTestDecorations) { + for (const d of decorationStressTestDecorations) { + d.dispose(); + } + decorationStressTestDecorations = undefined; + } else { + const t = term as Terminal; + const buffer = t.buffer.active; + const cursorY = buffer.baseY + buffer.cursorY; + decorationStressTestDecorations = []; + for (const x of [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95]) { + for (let y = 0; y < t.buffer.active.length; y++) { + const cursorOffsetY = y - cursorY; + decorationStressTestDecorations.push(t.registerDecoration({ + marker: t.registerMarker(cursorOffsetY), + x, + width: 4, + backgroundColor: '#FF0000', + overviewRulerOptions: { color: '#FF0000' } + })); + } + } + } +} + (console as any).image = (source: ImageData | HTMLCanvasElement, scale: number = 1) => { function getBox(width: number, height: number): any { return { diff --git a/demo/index.html b/demo/index.html index caff2ca2..238c9886 100644 --- a/demo/index.html +++ b/demo/index.html @@ -102,6 +102,7 @@
Decorations
+
Weblinks Addon
diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index a6349225..56edbc50 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -20,6 +20,8 @@ import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOption */ const CONSTRUCTOR_ONLY_OPTIONS = ['cols', 'rows']; +let $value = 0; + export class Terminal extends Disposable implements ITerminalApi { private _core: ITerminal; private _addonManager: AddonManager; @@ -249,16 +251,16 @@ export class Terminal extends Disposable implements ITerminalApi { } private _verifyIntegers(...values: number[]): void { - for (const value of values) { - if (value === Infinity || isNaN(value) || value % 1 !== 0) { + for ($value of values) { + if ($value === Infinity || isNaN($value) || $value % 1 !== 0) { throw new Error('This API only accepts integers'); } } } private _verifyPositiveIntegers(...values: number[]): void { - for (const value of values) { - if (value && (value === Infinity || isNaN(value) || value % 1 !== 0 || value < 0)) { + for ($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/common/SortedList.ts b/src/common/SortedList.ts index c3250091..82b6dfa6 100644 --- a/src/common/SortedList.ts +++ b/src/common/SortedList.ts @@ -3,16 +3,27 @@ * @license MIT */ +import { IdleTaskQueue } from 'common/TaskQueue'; + // Work variables to avoid garbage collection. let i = 0; /** - * A generic list that is maintained in sorted order and allows values with duplicate keys. This - * list is based on binary search and as such locating a key will take O(log n) amortized, this - * includes the by key iterator. + * A generic list that is maintained in sorted order and allows values with duplicate keys. Deferred + * batch insertion and deletion is used to significantly reduce the time it takes to insert and + * delete a large amount of items in succession. This list is based on binary search and as such + * locating a key will take O(log n) amortized, this includes the by key iterator. */ export class SortedList { - private readonly _array: T[] = []; + private _array: T[] = []; + + private readonly _insertedValues: T[] = []; + private readonly _flushInsertedTask = new IdleTaskQueue(); + private _isFlushingInserted = false; + + private readonly _deletedIndices: number[] = []; + private readonly _flushDeletedTask = new IdleTaskQueue(); + private _isFlushingDeleted = false; constructor( private readonly _getKey: (value: T) => number @@ -21,18 +32,50 @@ export class SortedList { public clear(): void { this._array.length = 0; + this._insertedValues.length = 0; + this._flushInsertedTask.clear(); + this._isFlushingInserted = false; + this._deletedIndices.length = 0; + this._flushDeletedTask.clear(); + this._isFlushingDeleted = false; } public insert(value: T): void { - if (this._array.length === 0) { - this._array.push(value); - return; + this._flushCleanupDeleted(); + if (this._insertedValues.length === 0) { + this._flushInsertedTask.enqueue(() => this._flushInserted()); + } + this._insertedValues.push(value); + } + + private _flushInserted(): void { + const sortedAddedValues = this._insertedValues.sort((a, b) => this._getKey(a) - this._getKey(b)); + let sortedAddedValuesIndex = 0; + let arrayIndex = 0; + + const newArray = new Array(this._array.length + this._insertedValues.length); + + for (let newArrayIndex = 0; newArrayIndex < newArray.length; newArrayIndex++) { + if (arrayIndex >= this._array.length || this._getKey(sortedAddedValues[sortedAddedValuesIndex]) <= this._getKey(this._array[arrayIndex])) { + newArray[newArrayIndex] = sortedAddedValues[sortedAddedValuesIndex]; + sortedAddedValuesIndex++; + } else { + newArray[newArrayIndex] = this._array[arrayIndex++]; + } + } + + this._array = newArray; + this._insertedValues.length = 0; + } + + private _flushCleanupInserted(): void { + if (!this._isFlushingInserted && this._insertedValues.length > 0) { + this._flushInsertedTask.flush(); } - i = this._search(this._getKey(value)); - this._array.splice(i, 0, value); } public delete(value: T): boolean { + this._flushCleanupInserted(); if (this._array.length === 0) { return false; } @@ -49,14 +92,43 @@ export class SortedList { } do { if (this._array[i] === value) { - this._array.splice(i, 1); + if (this._deletedIndices.length === 0) { + this._flushDeletedTask.enqueue(() => this._flushDeleted()); + } + this._deletedIndices.push(i); return true; } } while (++i < this._array.length && this._getKey(this._array[i]) === key); return false; } + private _flushDeleted(): void { + this._isFlushingDeleted = true; + const sortedDeletedIndices = this._deletedIndices.sort((a, b) => a - b); + let sortedDeletedIndicesIndex = 0; + const newArray = new Array(this._array.length - sortedDeletedIndices.length); + let newArrayIndex = 0; + for (let i = 0; i < this._array.length; i++) { + if (sortedDeletedIndices[sortedDeletedIndicesIndex] === i) { + sortedDeletedIndicesIndex++; + } else { + newArray[newArrayIndex++] = this._array[i]; + } + } + this._array = newArray; + this._deletedIndices.length = 0; + this._isFlushingDeleted = false; + } + + private _flushCleanupDeleted(): void { + if (!this._isFlushingDeleted && this._deletedIndices.length > 0) { + this._flushDeletedTask.flush(); + } + } + public *getKeyIterator(key: number): IterableIterator { + this._flushCleanupInserted(); + this._flushCleanupDeleted(); if (this._array.length === 0) { return; } @@ -73,6 +145,8 @@ export class SortedList { } public forEachByKey(key: number, callback: (value: T) => void): void { + this._flushCleanupInserted(); + this._flushCleanupDeleted(); if (this._array.length === 0) { return; } @@ -89,6 +163,8 @@ export class SortedList { } public values(): IterableIterator { + this._flushCleanupInserted(); + this._flushCleanupDeleted(); // Duplicate the array to avoid issues when _array changes while iterating return [...this._array].values(); } diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index da759152..c9be78af 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -45,7 +45,8 @@ export class DecorationService extends Disposable implements IDecorationService const decoration = new Decoration(options); if (decoration) { const markerDispose = decoration.marker.onDispose(() => decoration.dispose()); - decoration.onDispose(() => { + const listener = decoration.onDispose(() => { + listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { this._onDecorationRemoved.fire(decoration);