diff --git a/addons/addon-image/src/SixelHandler.ts b/addons/addon-image/src/SixelHandler.ts index 2f12fbe19a..1af2d85bcd 100644 --- a/addons/addon-image/src/SixelHandler.ts +++ b/addons/addon-image/src/SixelHandler.ts @@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler { const height = this._dec.height; // partial fix for https://github.com/jerch/xterm-addon-image/issues/37 - if (!width || ! height) { + if (!width || !height) { if (height) { this._storage.advanceCursor(height); } diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index fc7a433de8..18c82584ef 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -20,7 +20,7 @@ "package": "webpack", "pretest": "npm run build", "test": "nyc mocha out/**/*.test.js", - "prepublish": "npm run package" + "prepublishOnly": "npm run package" }, "keywords": [ "font", @@ -37,7 +37,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" diff --git a/eslint.config.mjs b/eslint.config.mjs index b73eece7a3..6c2f80e303 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ export default tseslint.config( singleline: { delimiter: 'comma', requireLast: false } }], '@stylistic/type-annotation-spacing': 'warn', + '@stylistic/space-unary-ops': 'warn', '@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }], '@typescript-eslint/consistent-type-assertions': 'warn', diff --git a/package-lock.json b/package-lock.json index bb713de747..f101299ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,7 +84,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" @@ -2546,9 +2546,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3895,9 +3895,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -3908,7 +3908,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts index e2455fd2b1..60f0a6ab95 100644 --- a/src/browser/services/MouseCoordsService.ts +++ b/src/browser/services/MouseCoordsService.ts @@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService { public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { return getCoords( - window, + getWindow(element), event, element, colCount, diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 83e3d33796..e999b7eac2 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); + private _intersectionObserver: IntersectionObserver | undefined; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService { // and resume based on terminal visibility if so if ('IntersectionObserver' in w) { const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 }); + this._observerDisposable.value = toDisposable(() => { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = undefined; + }); + this._intersectionObserver = observer; observer.observe(screenElement); - this._observerDisposable.value = toDisposable(() => observer.disconnect()); } } diff --git a/src/common/Async.ts b/src/common/Async.ts index 7345f19da8..276532fb88 100644 --- a/src/common/Async.ts +++ b/src/common/Async.ts @@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable { } } +/** + * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled + * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not + * run yet. + */ +export class MicrotaskTimer implements IDisposable { + private _isScheduled = false; + private _isDisposed = false; + + public dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + public cancel(): void { + this._isScheduled = false; + } + + public set(runner: () => void): void { + if (this._isDisposed) { + throw new Error('Calling set on a disposed MicrotaskTimer'); + } + if (this._isScheduled) { + return; + } + this._isScheduled = true; + queueMicrotask(() => { + if (!this._isScheduled) { + return; + } + this._isScheduled = false; + runner(); + }); + } +} + export class IntervalTimer implements IDisposable { private _disposable: IDisposable | undefined; private _isDisposed = false; diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e244..e3a6c2f045 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -4,11 +4,13 @@ */ import { assert } from 'chai'; -import { DecorationService } from './DecorationService'; +import { DecorationLineCache, DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { Buffer } from 'common/buffer/Buffer'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker { }()); } +function createDecorationService(): DecorationService { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + return new DecorationService(new MockLogService(), bufferService); +} + const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -35,7 +42,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -48,7 +55,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -74,7 +81,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -99,11 +106,35 @@ describe('DecorationService', () => { service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d)); assert.strictEqual(foundAtX8.length, 0); }); + + it('should find multi-line decoration when single-line decorations exist on other lines', () => { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + for (let i = 0; i < 100; i++) { + serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(i), + width: 5 + }); + } + const multiLine = serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(10), + width: 10, + height: 3 + }); + assert.ok(multiLine); + + const found: typeof multiLine[] = []; + serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d)); + assert.include(found, multiLine); + }); }); describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -117,4 +148,73 @@ describe('DecorationService', () => { assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0); }); }); + + describe('DecorationLineCache', () => { + it('should return undefined for lines with no indexed decorations', () => { + const cache = new DecorationLineCache(); + assert.isUndefined(cache.getDecorationsOnLine(0)); + }); + }); + + describe('line index maintenance', () => { + it('should keep lookups correct after buffer trim', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(buffer.lines.length - 1); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d)); + assert.strictEqual(found.length, 1); + }); + + it('should remove decoration from line index when marker is trimmed off buffer', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(0); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + assert.isTrue(marker.isDisposed); + assert.isTrue(decoration!.isDisposed); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d)); + assert.strictEqual(found.length, 0); + }); + + it('should keep multi-line decoration indexed after line insert', async () => { + const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(3); + const decoration = service.registerDecoration({ marker, width: 10, height: 3 }); + assert.ok(decoration); + + buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); + await new Promise(resolve => queueMicrotask(resolve)); + + const foundOnSpan: typeof decoration[] = []; + for (let line = marker.line; line < marker.line + 3; line++) { + service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d)); + } + assert.include(foundOnSpan, decoration); + + const foundOutsideSpan: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d)); + assert.strictEqual(foundOutsideSpan.length, 0); + }); + }); }); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985e..a582c18afb 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -3,19 +3,19 @@ * @license MIT */ +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { MicrotaskTimer } from 'common/Async'; import { css } from 'common/Color'; -import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, ICircularList } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; // Work variables to avoid garbage collection let $xmin = 0; let $xmax = 0; -let $ymin = 0; -let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; @@ -27,6 +27,8 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; + private readonly _lineCache = this._register(new DecorationLineCache()); + private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); @@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor(@ILogService private readonly _logService: ILogService) { + 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 { @@ -53,12 +62,14 @@ export class DecorationService extends Disposable implements IDecorationService 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; @@ -69,19 +80,17 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); + this._lineCache.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { + const bucket = this._lineCache.getDecorationsOnLine(line); + if (!bucket) { + return; + } let xmin = 0; let xmax = 0; - let ymin = 0; - let ymax = 0; - for (const d of this._decorations.values()) { - ymin = d.marker.line; - ymax = ymin + (d.options.height ?? 1); - if (line < ymin || line >= ymax) { - continue; - } + 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)) { @@ -91,12 +100,11 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - for (const d of this._decorations.values()) { - $ymin = d.marker.line; - $ymax = $ymin + (d.options.height ?? 1); - if (line < $ymin || line >= $ymax) { - continue; - } + 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)) { @@ -106,10 +114,235 @@ export class DecorationService extends Disposable implements IDecorationService } } +/** + * 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 = new Map(); + private readonly _decorations = new Set(); + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + 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 | undefined { + return this._decorationsByLine.get(line); + } + + public attachToBufferLines(lines: ICircularList): 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) { + return; + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line - amount; + if (newLine < 0) { + continue; + } + const existing = newMap.get(newLine); + if (existing) { + existing.push(...bucket); + } else { + newMap.set(newLine, bucket.slice()); + } + } + 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, line: number, bucket: IInternalDecoration[]): void { + const existing = newMap.get(line); + if (existing) { + existing.push(...bucket); + } 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(); + 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(); + 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()); public readonly onRender = this.onRenderEmitter.event; private readonly _onDispose = this.add(new Emitter()); @@ -144,6 +377,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + this._indexedStartLine = options.marker.line; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc6..a858193c3a 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration { readonly backgroundColorRGB: IColor | undefined; readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; } diff --git a/test/benchmark/DecorationService.benchmark.ts b/test/benchmark/DecorationService.benchmark.ts new file mode 100644 index 0000000000..1a2033de1b --- /dev/null +++ b/test/benchmark/DecorationService.benchmark.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { DecorationService } from 'common/services/DecorationService'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +const enum Constants { + COLS = 80, + ROWS = 30, + SINGLE_LINE_DECORATION_COUNT = 20_000, + MULTI_LINE_DECORATION_COUNT = 19_999, + MULTI_LINE_HEIGHT = 2, + VIEWPORT_SCAN_ITERATIONS = 20 +} + +function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void { + const buffer = bufferService.buffer; + for (let i = 0; i < count; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } +} + +function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void { + const buffer = bufferService.buffer; + for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } + const multiLineMarker = buffer.addMarker(10); + service.registerDecoration({ + marker: multiLineMarker, + width: Constants.COLS, + height: Constants.MULTI_LINE_HEIGHT + }); +} + +function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number { + let hitCount = 0; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + service.forEachDecorationAtCell(col, row, undefined, () => { + hitCount++; + }); + } + } + return hitCount; +} + +perfContext('DecorationService.forEachDecorationAtCell', () => { + perfContext('single-line dense / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 0, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('mixed single-line + multi-line / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerMixedDecorations(service, bufferService); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 10, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('viewport grid scan', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let totalHits = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits }; + }, { fork: false }).showAverageRuntime(); + }); +});