From 97a5069f8c15c283648576791375439486130e2f Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 31 Mar 2026 11:52:58 -0700 Subject: [PATCH 01/20] Refactor so isWrapped isn't public writeable. Instead to change isWrapped state use a Buffer.setWrapped method. This allows for potential flexibility in how BufferLine and line-wrapping are implemented. --- src/browser/TestUtils.test.ts | 3 ++ src/browser/services/SelectionService.test.ts | 12 +++--- src/common/InputHandler.ts | 42 +++++++++---------- src/common/Types.ts | 2 +- src/common/WindowsMode.ts | 6 +-- src/common/buffer/Buffer.test.ts | 34 +++++++-------- src/common/buffer/Buffer.ts | 7 +++- src/common/buffer/BufferLine.ts | 18 ++++++-- src/common/buffer/Types.ts | 1 + src/common/services/BufferService.ts | 3 +- 10 files changed, 73 insertions(+), 55 deletions(-) diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 485900e41a..b6bc43cffa 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -270,6 +270,9 @@ export class MockBuffer implements IBuffer { public clearAllMarkers(): void { throw new Error('Method not implemented.'); } + public setWrapped(row: number): void { + throw new Error('Method not implemented.'); + } } export class MockRenderer implements IRenderer { diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 8deedb751c..8ee04b7e3d 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -193,7 +193,7 @@ describe('SelectionService', () => { it('should expand upwards or downards for wrapped lines', () => { buffer.lines.set(0, stringToRow(' foo')); buffer.lines.set(1, stringToRow('bar ')); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); selectionService.selectWordAt([1, 1]); assert.equal(selectionService.selectionText, 'foobar'); selectionService.model.clearSelection(); @@ -207,10 +207,10 @@ describe('SelectionService', () => { buffer.lines.set(2, stringToRow('bbbbbbbbbbbbbbbbbbbb')); buffer.lines.set(3, stringToRow('cccccccccccccccccccc')); buffer.lines.set(4, stringToRow('bar ')); - buffer.lines.get(1)!.isWrapped = true; - buffer.lines.get(2)!.isWrapped = true; - buffer.lines.get(3)!.isWrapped = true; - buffer.lines.get(4)!.isWrapped = true; + buffer.setWrapped(1, true); + buffer.setWrapped(2, true); + buffer.setWrapped(3, true); + buffer.setWrapped(4, true); selectionService.selectWordAt([18, 0]); assert.equal(selectionService.selectionText, expectedText); selectionService.model.clearSelection(); @@ -349,8 +349,8 @@ describe('SelectionService', () => { it('should select the entire wrapped line', () => { buffer.lines.set(0, stringToRow('foo')); const line2 = stringToRow('bar'); - line2.isWrapped = true; buffer.lines.set(1, line2); + buffer.setWrapped(1, true); selectionService.selectLineAt(0); assert.equal(selectionService.selectionText, 'foobar', 'The selected text is correct'); assert.deepEqual(selectionService.model.selectionStart, [0, 0]); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 68265c5df4..361eb8d875 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -609,7 +609,7 @@ export class InputHandler extends Disposable implements IInputHandler { } // The line already exists (eg. the initial viewport), mark it as a // wrapped line - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, true); } // row changed, get it again bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y); @@ -773,7 +773,7 @@ export class InputHandler extends Disposable implements IInputHandler { // reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics // can mess with this so windowsMode should be disabled, which is recommended on Windows build // 21376 and above. - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, false); } // If the end of the line is hit, prevent this action from wrapping around to the next line. if (this._activeBuffer.x >= this._bufferService.cols) { @@ -837,7 +837,7 @@ export class InputHandler extends Disposable implements IInputHandler { && this._activeBuffer.y > this._activeBuffer.scrollTop && this._activeBuffer.y <= this._activeBuffer.scrollBottom && this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) { - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, false); this._activeBuffer.y--; this._activeBuffer.x = this._bufferService.cols - 1; // find last taken cell - last cell can have 3 different states: @@ -1201,7 +1201,7 @@ export class InputHandler extends Disposable implements IInputHandler { respectProtect ); if (clearWrap) { - line.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + y, false); } } @@ -1215,7 +1215,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (line) { line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); - line.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + y, false); } } @@ -1263,10 +1263,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect); if (this._activeBuffer.x + 1 >= this._bufferService.cols) { // Deleted entire previous line. This next line can no longer be wrapped. - const nextLine = this._activeBuffer.lines.get(j + 1); - if (nextLine) { - nextLine.isWrapped = false; - } + this._activeBuffer.setWrapped(j + 1, false); } while (j--) { this._resetBufferLine(j, respectProtect); @@ -1528,9 +1525,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; + this._activeBuffer.setWrapped(row, false); } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1561,9 +1559,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; + this._activeBuffer.setWrapped(row, false); line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1584,9 +1583,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + this._activeBuffer.setWrapped(row, false); + const line = this._activeBuffer.lines.get(row)!; line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1607,9 +1607,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; + this._activeBuffer.setWrapped(row, false); line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -3494,11 +3495,8 @@ export class InputHandler extends Disposable implements IInputHandler { this._setCursor(0, 0); for (let yOffset = 0; yOffset < this._bufferService.rows; ++yOffset) { const row = this._activeBuffer.ybase + this._activeBuffer.y + yOffset; - const line = this._activeBuffer.lines.get(row); - if (line) { - line.fill(cell); - line.isWrapped = false; - } + this._activeBuffer.setWrapped(row, false); + this._activeBuffer.lines.get(row)?.fill(cell); } this._dirtyRowTracker.markAllDirty(); this._setCursor(0, 0); diff --git a/src/common/Types.ts b/src/common/Types.ts index 0a9c89a1a0..4e5cbb19a8 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -224,7 +224,7 @@ export interface ICellData extends IAttributeData { */ export interface IBufferLine { length: number; - isWrapped: boolean; + get isWrapped(): boolean; get(index: number): CharData; set(index: number, value: CharData): void; loadCell(index: number, cell: ICellData): ICellData; diff --git a/src/common/WindowsMode.ts b/src/common/WindowsMode.ts index 7cff094b2c..22ba1e2a92 100644 --- a/src/common/WindowsMode.ts +++ b/src/common/WindowsMode.ts @@ -19,9 +19,7 @@ export function updateWindowsModeWrappedState(bufferService: IBufferService): vo // wrapped. const line = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y - 1); const lastChar = line?.get(bufferService.cols - 1); - - const nextLine = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y); - if (nextLine && lastChar) { - nextLine.isWrapped = (lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE); + if (lastChar) { + bufferService.buffer.setWrapped(bufferService.buffer.ybase + bufferService.buffer.y, lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE); } } diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 9c013c5def..dd0d90fb93 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -68,40 +68,40 @@ describe('Buffer', () => { describe('wrapped', () => { it('should return a range for the first row', () => { buffer.fillViewportRows(); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 1 }); }); it('should return a range for a middle row wrapping upwards', () => { buffer.fillViewportRows(); - buffer.lines.get(12)!.isWrapped = true; + buffer.setWrapped(12, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 11, last: 12 }); }); it('should return a range for a middle row wrapping downwards', () => { buffer.fillViewportRows(); - buffer.lines.get(13)!.isWrapped = true; + buffer.setWrapped(13, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 13 }); }); it('should return a range for a middle row wrapping both ways', () => { buffer.fillViewportRows(); - buffer.lines.get(11)!.isWrapped = true; - buffer.lines.get(12)!.isWrapped = true; - buffer.lines.get(13)!.isWrapped = true; - buffer.lines.get(14)!.isWrapped = true; + buffer.setWrapped(11, true); + buffer.setWrapped(12, true); + buffer.setWrapped(13, true); + buffer.setWrapped(14, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 10, last: 14 }); }); it('should return a range for the last row', () => { buffer.fillViewportRows(); - buffer.lines.get(23)!.isWrapped = true; + buffer.setWrapped(23, true); assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 22, last: 23 }); }); it('should return a range for a row that wraps upward to first row', () => { buffer.fillViewportRows(); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); assert.deepEqual(buffer.getWrappedRangeForLine(1), { first: 0, last: 1 }); }); it('should return a range for a row that wraps downward to last row', () => { buffer.fillViewportRows(); - buffer.lines.get(buffer.lines.length - 1)!.isWrapped = true; + buffer.setWrapped(buffer.lines.length - 1, true); assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 2), { first: 22, last: 23 }); }); }); @@ -526,7 +526,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // "ab " (wrapped) // "cd" @@ -557,7 +557,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(i, [0, '', 0, 0]); buffer.lines.get(1)!.set(i, [0, '', 0, 0]); } - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // 汉语汉语汉语 (wrapped) // 汉语汉语汉语 @@ -584,7 +584,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // "ab " (wrapped) // "cd" @@ -618,7 +618,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(i, [0, '', 0, 0]); buffer.lines.get(1)!.set(i, [0, '', 0, 0]); } - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // 汉语汉语汉语 (wrapped) // 汉语汉语汉语 @@ -673,17 +673,17 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); buffer.lines.get(2)!.set(0, [0, 'e', 1, 'e'.charCodeAt(0)]); buffer.lines.get(2)!.set(1, [0, 'f', 1, 'f'.charCodeAt(0)]); buffer.lines.get(3)!.set(0, [0, 'g', 1, 'g'.charCodeAt(0)]); buffer.lines.get(3)!.set(1, [0, 'h', 1, 'h'.charCodeAt(0)]); - buffer.lines.get(3)!.isWrapped = true; + buffer.setWrapped(3, true); buffer.lines.get(4)!.set(0, [0, 'i', 1, 'i'.charCodeAt(0)]); buffer.lines.get(4)!.set(1, [0, 'j', 1, 'j'.charCodeAt(0)]); buffer.lines.get(5)!.set(0, [0, 'k', 1, 'k'.charCodeAt(0)]); buffer.lines.get(5)!.set(1, [0, 'l', 1, 'l'.charCodeAt(0)]); - buffer.lines.get(5)!.isWrapped = true; + buffer.setWrapped(5, true); }); describe('viewport not yet filled', () => { it('should move the cursor up and add empty lines', () => { diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 8efefd60a4..b7de7e7416 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -121,6 +121,11 @@ export class Buffer implements IBuffer { return correctBufferLength > MAX_BUFFER_SIZE ? MAX_BUFFER_SIZE : correctBufferLength; } + public setWrapped(absrow: number, value: boolean): void { + const line = this.lines.get(absrow); + line instanceof BufferLine && line.setWrapped(value); + } + /** * Fills the buffer's viewport with blank lines. */ @@ -135,7 +140,7 @@ export class Buffer implements IBuffer { } /** - * Clears the buffer to it's initial state, discarding all previous data. + * Clears the buffer to its initial state, discarding all previous data. */ public clear(): void { this.ydisp = 0; diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e415851a0a..b7921942c8 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -64,14 +64,26 @@ export class BufferLine implements IBufferLine { protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; public length: number; + private _isWrapped: boolean; - constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { + public get isWrapped(): boolean { + return this._isWrapped; + } + /** + * @internal + */ + public setWrapped(isWrapped: boolean): void { + this._isWrapped = isWrapped; + } + + constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { this._data = new Uint32Array(cols * CELL_SIZE); const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); for (let i = 0; i < cols; ++i) { this.setCell(i, cell); } this.length = cols; + this._isWrapped = isWrapped; } /** @@ -439,7 +451,7 @@ export class BufferLine implements IBufferLine { for (const el in line._extendedAttrs) { this._extendedAttrs[el] = line._extendedAttrs[el]; } - this.isWrapped = line.isWrapped; + this._isWrapped = line._isWrapped; } /** create a new clone */ @@ -453,7 +465,7 @@ export class BufferLine implements IBufferLine { for (const el in this._extendedAttrs) { newLine._extendedAttrs[el] = this._extendedAttrs[el]; } - newLine.isWrapped = this.isWrapped; + newLine._isWrapped = this._isWrapped; return newLine; } diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 85dd68eec2..a7e7321f2f 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -39,6 +39,7 @@ export interface IBuffer { addMarker(y: number): IMarker; clearMarkers(y: number): void; clearAllMarkers(): void; + setWrapped(row: number, value: boolean): void; } export interface IBufferSet extends IDisposable { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 6a7ce2b343..e71bc04ab1 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -6,6 +6,7 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine } from 'common/Types'; import { BufferSet } from 'common/buffer/BufferSet'; +import { BufferLine } from 'common/buffer/BufferLine'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; import { Emitter } from 'common/Event'; @@ -73,7 +74,7 @@ export class BufferService extends Disposable implements IBufferService { newLine = buffer.getBlankLine(eraseAttr, isWrapped); this._cachedBlankLine = newLine; } - newLine.isWrapped = isWrapped; + (newLine as BufferLine).setWrapped(isWrapped); const topRow = buffer.ybase + buffer.scrollTop; const bottomRow = buffer.ybase + buffer.scrollBottom; From e9ea22f00556021ac821b210efad0762e5bc79bf Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 5 Apr 2026 21:27:51 -0700 Subject: [PATCH 02/20] Fix MockBuffer.setWrapped. --- src/browser/TestUtils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index b6bc43cffa..8fd6cb2cf5 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -270,7 +270,7 @@ export class MockBuffer implements IBuffer { public clearAllMarkers(): void { throw new Error('Method not implemented.'); } - public setWrapped(row: number): void { + public setWrapped(row: number, value: boolean): void { throw new Error('Method not implemented.'); } } From bd79ccc82acb8cd87756378bd6b30007c9fca310 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 6 Apr 2026 18:42:22 -0700 Subject: [PATCH 03/20] Move "data" from BufferLine to new LogicalLine class. A BufferLine is now the sub-range of a LogicalLine for a specific visible line, while LogicalLine is independent of window width. --- src/common/CircularList.ts | 14 - src/common/InputHandler.test.ts | 9 +- src/common/InputHandler.ts | 61 ++- src/common/Types.ts | 2 - src/common/buffer/Buffer.test.ts | 29 -- src/common/buffer/Buffer.ts | 225 ++++---- src/common/buffer/BufferLine.test.ts | 49 +- src/common/buffer/BufferLine.ts | 694 ++++++++++++++++++------- src/common/buffer/BufferReflow.test.ts | 68 ++- src/common/buffer/BufferReflow.ts | 145 +----- src/common/buffer/CellData.ts | 4 +- src/common/buffer/Constants.ts | 1 + src/common/buffer/Types.ts | 20 + src/common/services/BufferService.ts | 34 +- 14 files changed, 797 insertions(+), 558 deletions(-) diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 3663bfc178..ffa3719f7c 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -115,20 +115,6 @@ export class CircularList extends Disposable implements ICircularList { } } - /** - * Advance ringbuffer index and return current element for recycling. - * Note: The buffer must be full for this method to work. - * @throws When the buffer is not full. - */ - public recycle(): T { - if (this._length !== this._maxLength) { - throw new Error('Can only recycle when the buffer is full'); - } - this._startIndex = ++this._startIndex % this._maxLength; - this.onTrimEmitter.fire(1); - return this._array[this._getCyclicIndex(this._length - 1)]!; - } - /** * Ringbuffer is at max length. */ diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index f9da81a25e..12ca3fa472 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -459,8 +459,10 @@ describe('InputHandler', () => { await resetToBaseState(); bufferService.buffer.y = 2; bufferService.buffer.x = 40; - inputHandler.eraseInLine(Params.fromArray([0])); assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, true); + assert.equal(bufferService.buffer.lines.get(3)!.isWrapped, true); + inputHandler.eraseInLine(Params.fromArray([0])); + assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, true);assert.equal(bufferService.buffer.lines.get(3)!.isWrapped, false); bufferService.buffer.y = 2; bufferService.buffer.x = 0; inputHandler.eraseInLine(Params.fromArray([0])); @@ -471,14 +473,15 @@ describe('InputHandler', () => { bufferService.buffer.y = 2; bufferService.buffer.x = 40; inputHandler.eraseInLine(Params.fromArray([1])); - assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, true); + assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, false); + assert.equal(bufferService.buffer.lines.get(3)!.isWrapped, true); // params[2] - erase complete line await resetToBaseState(); bufferService.buffer.y = 2; bufferService.buffer.x = 40; inputHandler.eraseInLine(Params.fromArray([2])); - assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, false); + assert.equal(bufferService.buffer.lines.get(2)!.isWrapped, false);assert.equal(bufferService.buffer.lines.get(3)!.isWrapped, false); }); it('ED2 with scrollOnEraseInDisplay turned on', async () => { const inputHandler = new TestInputHandler( diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 361eb8d875..e7482902d0 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -596,8 +596,8 @@ export class InputHandler extends Disposable implements IInputHandler { // autowrap - DECAWM // automatically wraps to the beginning of the next line if (wraparoundMode) { - const oldRow = bufferRow; - let oldCol = this._activeBuffer.x - oldWidth; + const oldRow = bufferRow as BufferLine; + const oldCol = this._activeBuffer.x - oldWidth; this._activeBuffer.x = oldWidth; this._activeBuffer.y++; if (this._activeBuffer.y === this._activeBuffer.scrollBottom + 1) { @@ -623,9 +623,7 @@ export class InputHandler extends Disposable implements IInputHandler { oldCol, 0, oldWidth, false); } // clear left over cells to the right - while (oldCol < cols) { - oldRow.setCellFromCodepoint(oldCol++, 0, 1, curAttr); - } + oldRow.eraseRight(oldCol); } else { this._activeBuffer.x = cols - 1; if (chWidth === 2) { @@ -1183,24 +1181,33 @@ export class InputHandler extends Disposable implements IInputHandler { /** * Helper method to erase cells in a terminal row. * The cell gets replaced with the eraseChar of the terminal. + * Clear isWrapped if start===0; + * clear isWrapped of next line if end >= cols. * @param y The row index relative to the viewport. * @param start The start x index of the range to be erased. * @param end The end x index of the range to be erased (exclusive). - * @param clearWrap clear the isWrapped flag * @param respectProtect Whether to respect the protection attribute (DECSCA). */ - private _eraseInBufferLine(y: number, start: number, end: number, clearWrap: boolean = false, respectProtect: boolean = false): void { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y); - if (!line) { + private _eraseInBufferLine(y: number, start: number, end: number, respectProtect: boolean = false): void { + const yAbs = y + this._activeBuffer.ybase; + const line = this._activeBuffer.lines.get(yAbs); + if (! (line instanceof BufferLine)) { return; } - line.replaceCells( - start, - end, - this._activeBuffer.getNullCell(this._eraseAttrData()), - respectProtect - ); - if (clearWrap) { + if (! respectProtect && end >= this._bufferService.cols) { + const next = line.nextBufferLine; + if (next) next.asUnwrapped(line); + line.eraseRight(start); + line.logicalLine.backgroundColor = this._curAttrData.bg & ~0xFC000000; + } else { + line.replaceCells( + start, + end, + this._activeBuffer.getNullCell(this._eraseAttrData()), + respectProtect + ); + } + if (start === 0) { this._activeBuffer.setWrapped(this._activeBuffer.ybase + y, false); } } @@ -1211,12 +1218,8 @@ export class InputHandler extends Disposable implements IInputHandler { * @param y row index */ private _resetBufferLine(y: number, respectProtect: boolean = false): void { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y); - if (line) { - line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); - this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); - this._activeBuffer.setWrapped(this._activeBuffer.ybase + y, false); - } + this._eraseInBufferLine(y, 0, this._bufferService.cols, respectProtect); + this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); } /** @@ -1250,7 +1253,7 @@ export class InputHandler extends Disposable implements IInputHandler { case 0: j = this._activeBuffer.y; this._dirtyRowTracker.markDirty(j); - this._eraseInBufferLine(j++, this._activeBuffer.x, this._bufferService.cols, this._activeBuffer.x === 0, respectProtect); + this._eraseInBufferLine(j++, this._activeBuffer.x, this._bufferService.cols, respectProtect); for (; j < this._bufferService.rows; j++) { this._resetBufferLine(j, respectProtect); } @@ -1260,11 +1263,7 @@ export class InputHandler extends Disposable implements IInputHandler { j = this._activeBuffer.y; this._dirtyRowTracker.markDirty(j); // Deleted front part of line and everything before. This line will no longer be wrapped. - this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect); - if (this._activeBuffer.x + 1 >= this._bufferService.cols) { - // Deleted entire previous line. This next line can no longer be wrapped. - this._activeBuffer.setWrapped(j + 1, false); - } + this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, respectProtect); while (j--) { this._resetBufferLine(j, respectProtect); } @@ -1334,13 +1333,13 @@ export class InputHandler extends Disposable implements IInputHandler { this._restrictCursor(this._bufferService.cols); switch (params.params[0]) { case 0: - this._eraseInBufferLine(this._activeBuffer.y, this._activeBuffer.x, this._bufferService.cols, this._activeBuffer.x === 0, respectProtect); + this._eraseInBufferLine(this._activeBuffer.y, this._activeBuffer.x, this._bufferService.cols, respectProtect); break; case 1: - this._eraseInBufferLine(this._activeBuffer.y, 0, this._activeBuffer.x + 1, false, respectProtect); + this._eraseInBufferLine(this._activeBuffer.y, 0, this._activeBuffer.x + 1, respectProtect); break; case 2: - this._eraseInBufferLine(this._activeBuffer.y, 0, this._bufferService.cols, true, respectProtect); + this._eraseInBufferLine(this._activeBuffer.y, 0, this._bufferService.cols, respectProtect); break; } this._dirtyRowTracker.markDirty(this._activeBuffer.y); diff --git a/src/common/Types.ts b/src/common/Types.ts index 4e5cbb19a8..073f964913 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -77,7 +77,6 @@ export interface ICircularList { get(index: number): T | undefined; set(index: number, value: T): void; push(value: T): void; - recycle(): T; pop(): T | undefined; splice(start: number, deleteCount: number, ...items: T[]): void; trimStart(count: number): void; @@ -238,7 +237,6 @@ export interface IBufferLine { cleanupMemory(): number; fill(fillCellData: ICellData, respectProtect?: boolean): void; copyFrom(line: IBufferLine): void; - clone(): IBufferLine; getTrimmedLength(): number; getNoBgTrimmedLength(): number; translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string; diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index dd0d90fb93..fb6eadd83d 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1178,33 +1178,4 @@ describe('Buffer', () => { assert.equal(str3, '😁a'); }); }); - - describe('memory cleanup after shrinking', () => { - it('should realign memory from idle task execution', async () => { - buffer.fillViewportRows(); - - // shrink more than 2 times to trigger lazy memory cleanup - buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS); - - // sync - for (let i = 0; i < INIT_ROWS; i++) { - const line = buffer.lines.get(i)!; - // line memory is still at old size from initialization - assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4); - // array.length and .length get immediately adjusted - assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3); - assert.equal(line.length, INIT_COLS / 2 - 1); - } - - // wait for a bit to give IdleTaskQueue a chance to kick in - // and finish memory cleaning - await new Promise(r => setTimeout(r, 30)); - - // cleanup should have realigned memory with exact bytelength - for (let i = 0; i < INIT_ROWS; i++) { - const line = buffer.lines.get(i)!; - assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4); - } - }); - }); }); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index b7de7e7416..cc322c641c 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -4,11 +4,10 @@ */ import { CircularList, IInsertEvent } from 'common/CircularList'; -import { IdleTaskQueue } from 'common/TaskQueue'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; -import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; -import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow'; +import { BufferLine, LogicalLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { reflowLargerApplyNewLayout, reflowLargerCreateNewLayout } from 'common/buffer/BufferReflow'; import { CellData } from 'common/buffer/CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants'; import { Marker } from 'common/buffer/Marker'; @@ -29,6 +28,7 @@ export class Buffer implements IBuffer { public lines: CircularList; public ydisp: number = 0; public ybase: number = 0; + /** Row number, relative to ybase. */ public y: number = 0; public x: number = 0; public scrollBottom: number; @@ -48,8 +48,6 @@ export class Buffer implements IBuffer { private _cols: number; private _rows: number; private _isClearing: boolean = false; - private _memoryCleanupQueue: InstanceType; - private _memoryCleanupPosition = 0; constructor( private _hasScrollback: boolean, @@ -63,7 +61,13 @@ export class Buffer implements IBuffer { this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); - this._memoryCleanupQueue = new IdleTaskQueue(this._logService); + + this.lines.onTrim(amount => { + const first = this.lines.length && this.lines.get(0); + if (first instanceof BufferLine && first.isWrapped) { + const prev = first.getPreviousLine(); + prev && first.asUnwrapped(prev); + }}); } public getNullCell(attr?: IAttributeData): ICellData { @@ -92,8 +96,14 @@ export class Buffer implements IBuffer { return this._whitespaceCell; } - public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped); + /** + * Get an empty unwrapped line. + * @param attr Only used for the background color. + */ + public getBlankLine(attr: IAttributeData): IBufferLine { + const lline = new LogicalLine(this._cols); + lline.backgroundColor = attr.bg & ~0xFC000000; + return new BufferLine(this._cols, lline); } public get hasScrollback(): boolean { @@ -123,7 +133,14 @@ export class Buffer implements IBuffer { public setWrapped(absrow: number, value: boolean): void { const line = this.lines.get(absrow); - line instanceof BufferLine && line.setWrapped(value); + if (! line || line.isWrapped === value) + {return;} + const prevRow = this.lines.get(absrow - 1) as BufferLine; + if (value) { + (line as BufferLine).setWrapped(prevRow); + } else { + (line as BufferLine).asUnwrapped(prevRow); + } } /** @@ -160,10 +177,6 @@ export class Buffer implements IBuffer { */ public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs - const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); - - // count bufferlines with overly big memory to be cleaned afterwards - let dirtyMemoryLines = 0; // Increase max length if needed before adjustments to allow space to fill // as required. @@ -182,8 +195,7 @@ export class Buffer implements IBuffer { // Deal with columns increasing (reducing needs to happen after reflow) if (this._cols < newCols) { for (let i = 0; i < this.lines.length; i++) { - // +boolean for fast 0 or 1 conversion - dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell); + this.lines.get(i)!.length = newCols; } } @@ -193,9 +205,9 @@ export class Buffer implements IBuffer { for (let y = this._rows; y < newRows; y++) { if (this.lines.length < newRows + this.ybase) { if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { - // Just add the new missing rows on Windows as conpty reprints the screen with it's + // Just add the new missing rows on Windows as conpty reprints the screen with its // view of the world. Once a line enters scrollback for conpty it remains there - this.lines.push(new BufferLine(newCols, nullCell)); + this.lines.push(new BufferLine(newCols)); } else { if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { // There is room above the buffer and there are no empty elements below the line, @@ -209,7 +221,7 @@ export class Buffer implements IBuffer { } else { // Add a blank line if there is no buffer left at the top to scroll to, or if there // are blank lines after the cursor - this.lines.push(new BufferLine(newCols, nullCell)); + this.lines.push(new BufferLine(newCols)); } } } @@ -262,8 +274,7 @@ export class Buffer implements IBuffer { // Trim the end of the line off if cols shrunk if (this._cols > newCols) { for (let i = 0; i < this.lines.length; i++) { - // +boolean for fast 0 or 1 conversion - dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell); + this.lines.get(i)!.length = newCols; } } } @@ -277,35 +288,6 @@ export class Buffer implements IBuffer { const maxY = Math.max(0, this.lines.length - this.ybase - 1); this.y = Math.min(this.y, maxY); } - - this._memoryCleanupQueue.clear(); - // schedule memory cleanup only, if more than 10% of the lines are affected - if (dirtyMemoryLines > 0.1 * this.lines.length) { - this._memoryCleanupPosition = 0; - this._memoryCleanupQueue.enqueue(() => this._batchedMemoryCleanup()); - } - } - - private _batchedMemoryCleanup(): boolean { - let normalRun = true; - if (this._memoryCleanupPosition >= this.lines.length) { - // cleanup made it once through all lines, thus rescan in loop below to also catch shifted - // lines, which should finish rather quick if there are no more cleanups pending - this._memoryCleanupPosition = 0; - normalRun = false; - } - let counted = 0; - while (this._memoryCleanupPosition < this.lines.length) { - counted += this.lines.get(this._memoryCleanupPosition++)!.cleanupMemory(); - // cleanup max 100 lines per batch - if (counted > 100) { - return true; - } - } - // normal runs always need another rescan afterwards - // if we made it here with normalRun=false, we are in a final run - // and can end the cleanup task for sure - return normalRun; } private get _isReflowEnabled(): boolean { @@ -329,9 +311,62 @@ export class Buffer implements IBuffer { } } + /** + * Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed + * when a wrapped line unwraps. + * @param lines The buffer lines. + * @param oldCols The columns before resize + * @param newCols The columns after resize. + * @param bufferAbsoluteY The absolute y position of the cursor (baseY + cursorY). + * @param nullCell The cell data to use when filling in empty cells. + * @param reflowCursorLine Whether to reflow the line containing the cursor. + */ + private _reflowLargerGetLinesToRemove(lines: CircularList, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] { + // Gather all BufferLines that need to be removed from the Buffer here so that they can be + // batched up and only committed once + const toRemove: number[] = []; + + for (let y = 0; y < lines.length - 1; y++) { + // Check if this row is wrapped + let i = y; + let nextLine = lines.get(++i) as BufferLine; + if (!nextLine.isWrapped) { + continue; + } + + // Check how many lines it's wrapped for + const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine]; + while (i < lines.length && nextLine.isWrapped) { + wrappedLines.push(nextLine); + nextLine = lines.get(++i) as BufferLine; + } + + if (!reflowCursorLine) { + // If these lines contain the cursor don't touch them, the program will handle fixing up + // wrapped lines with the cursor + if (bufferAbsoluteY >= y && bufferAbsoluteY < i) { + y += wrappedLines.length - 1; + continue; + } + } + const oldWrapped = wrappedLines.length; + this._reflowLine(wrappedLines, newCols); + + // Work backwards and remove any rows at the end that only contain null cells + const countToRemove = oldWrapped - wrappedLines.length; + if (countToRemove > 0) { + toRemove.push(y + oldWrapped - countToRemove); // index + toRemove.push(countToRemove); + } + + y += oldWrapped - 1; + } + return toRemove; + } + private _reflowLarger(newCols: number, newRows: number): void { const reflowCursorLine = this._optionsService.rawOptions.reflowCursorLine; - const toRemove: number[] = reflowLargerGetLinesToRemove(this.lines, this._cols, newCols, this.ybase + this.y, this.getNullCell(DEFAULT_ATTR_DATA), reflowCursorLine); + const toRemove: number[] = this._reflowLargerGetLinesToRemove(this.lines, this._cols, newCols, this.ybase + this.y, this.getNullCell(DEFAULT_ATTR_DATA), reflowCursorLine); if (toRemove.length > 0) { const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); @@ -340,7 +375,6 @@ export class Buffer implements IBuffer { } private _reflowLargerAdjustViewport(newCols: number, newRows: number, countRemoved: number): void { - const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); // Adjust viewport based on number of items removed let viewportAdjustments = countRemoved; while (viewportAdjustments-- > 0) { @@ -350,7 +384,7 @@ export class Buffer implements IBuffer { } if (this.lines.length < newRows) { // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(newCols, nullCell)); + this.lines.push(new BufferLine(newCols)); } } else { if (this.ydisp === this.ybase) { @@ -361,10 +395,42 @@ export class Buffer implements IBuffer { } this.savedY = Math.max(this.savedY - countRemoved, 0); } + private _reflowLine(wrappedLines: BufferLine[], newCols: number): BufferLine[] { + const newLines: BufferLine[] = []; + let startCol = 0; + let curRow = 1; + let curLine = wrappedLines[0]; + const logical = curLine.logicalLine; + for (;;) { + const endCol = logical.charStart(startCol + newCols); + if ((this as any).xyz) console.log('-curR:'+curRow+' endCol:'+endCol); + if (endCol >= logical.length) { + curLine.nextBufferLine = undefined; + curLine.startColumn = startCol; + break; + } + let newLine; + if (curRow < wrappedLines.length) { + newLine = wrappedLines[curRow]; + newLine.length = newCols; + } else { + newLine = new BufferLine(newCols, logical); + newLines.push(newLine); + } + curRow++; + newLine.startColumn = endCol; + startCol = endCol; + curLine.nextBufferLine = newLine; + curLine = newLine; + } + if (curRow < wrappedLines.length) { + wrappedLines.length = curRow; + } + return newLines; + } private _reflowSmaller(newCols: number, newRows: number): void { const reflowCursorLine = this._optionsService.rawOptions.reflowCursorLine; - const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); // Gather all BufferLines that need to be inserted into the Buffer here so that they can be // batched up and only committed once const toInsert = []; @@ -376,7 +442,6 @@ export class Buffer implements IBuffer { if (!nextLine || !nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) { continue; } - // Gather wrapped lines and adjust y to be the starting line const wrappedLines: BufferLine[] = [nextLine]; while (nextLine.isWrapped && y > 0) { @@ -392,10 +457,8 @@ export class Buffer implements IBuffer { continue; } } - - const lastLineLength = wrappedLines[wrappedLines.length - 1].getTrimmedLength(); - const destLineLengths = reflowSmallerGetNewLineLengths(wrappedLines, this._cols, newCols); - const linesToAdd = destLineLengths.length - wrappedLines.length; + const newLines = this._reflowLine(wrappedLines, newCols); + const linesToAdd = newLines.length; let trimmedLines: number; if (this.ybase === 0 && this.y !== this.lines.length - 1) { // If the top section of the buffer is not yet filled @@ -404,12 +467,6 @@ export class Buffer implements IBuffer { trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd); } - // Add the new lines - const newLines: BufferLine[] = []; - for (let i = 0; i < linesToAdd; i++) { - const newLine = this.getBlankLine(DEFAULT_ATTR_DATA, true) as BufferLine; - newLines.push(newLine); - } if (newLines.length > 0) { toInsert.push({ // countToInsert here gets the actual index, taking into account other inserted items. @@ -418,46 +475,8 @@ export class Buffer implements IBuffer { newLines }); countToInsert += newLines.length; + wrappedLines.push(...newLines); } - wrappedLines.push(...newLines); - - // Copy buffer data to new locations, this needs to happen backwards to do in-place - let destLineIndex = destLineLengths.length - 1; // Math.floor(cellsNeeded / newCols); - let destCol = destLineLengths[destLineIndex]; // cellsNeeded % newCols; - if (destCol === 0) { - destLineIndex--; - destCol = destLineLengths[destLineIndex]; - } - let srcLineIndex = wrappedLines.length - linesToAdd - 1; - let srcCol = lastLineLength; - while (srcLineIndex >= 0) { - const cellsToCopy = Math.min(srcCol, destCol); - if (wrappedLines[destLineIndex] === undefined) { - // Sanity check that the line exists, this has been known to fail for an unknown reason - // which would stop the reflow from happening if an exception would throw. - break; - } - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); - destCol -= cellsToCopy; - if (destCol === 0) { - destLineIndex--; - destCol = destLineLengths[destLineIndex]; - } - srcCol -= cellsToCopy; - if (srcCol === 0) { - srcLineIndex--; - const wrappedLinesIndex = Math.max(srcLineIndex, 0); - srcCol = getWrappedLineTrimmedLength(wrappedLines, wrappedLinesIndex, this._cols); - } - } - - // Null out the end of the line ends if a wide character wrapped to the following line - for (let i = 0; i < wrappedLines.length; i++) { - if (destLineLengths[i] < newCols) { - wrappedLines[i].setCell(destLineLengths[i], nullCell); - } - } - // Adjust viewport as needed let viewportAdjustments = linesToAdd - trimmedLines; while (viewportAdjustments-- > 0) { diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index dce68ebbfe..8e78b248d6 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -3,7 +3,7 @@ * @license MIT */ import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants'; -import { BufferLine } from 'common/buffer//BufferLine'; +import { BufferLine, LogicalLine } from 'common/buffer//BufferLine'; import { CellData } from 'common/buffer/CellData'; import { CharData, IBufferLine } from '../Types'; import { assert } from 'chai'; @@ -12,8 +12,21 @@ import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestU class TestBufferLine extends BufferLine { - public get combined(): {[index: number]: string} { - return this._combined; + constructor(cols: number, fillCellData?: CellData, isWrapped: boolean = false) { + const lline = new LogicalLine(isWrapped ? 2 * cols : cols); + super(cols, lline); + if (isWrapped) { + const prevLine = new BufferLine(cols, lline); + lline.firstBufferLine = prevLine; + prevLine.nextBufferLine = this; + this.startColumn = cols; + fillCellData && prevLine.fill(fillCellData); + } else { + lline.firstBufferLine = this; + } + if (fillCellData) { + this.fill(fillCellData); + } } public toArray(): CharData[] { @@ -239,18 +252,6 @@ describe('BufferLine', function(): void { [123, 'z', 1, 'z'.charCodeAt(0)] ]); }); - it('clone', function(): void { - const line = new TestBufferLine(5, undefined, true); - line.setCell(0, createCellData(1, 'a', 1)); - line.setCell(1, createCellData(2, 'b', 1)); - line.setCell(2, createCellData(3, 'c', 1)); - line.setCell(3, createCellData(4, 'd', 1)); - line.setCell(4, createCellData(5, 'e', 1)); - const line2 = line.clone(); - assert.deepEqual(TestBufferLine.prototype.toArray.apply(line2), line.toArray()); - assert.equal(line2.length, line.length); - assert.equal(line2.isWrapped, line.isWrapped); - }); it('copyFrom', function(): void { const line = new TestBufferLine(5); line.setCell(0, createCellData(1, 'a', 1)); @@ -258,11 +259,10 @@ describe('BufferLine', function(): void { line.setCell(2, createCellData(3, 'c', 1)); line.setCell(3, createCellData(4, 'd', 1)); line.setCell(4, createCellData(5, 'e', 1)); - const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true); + const line2 = new TestBufferLine(5, createCellData(1, 'a', 1)); line2.copyFrom(line); assert.deepEqual(line2.toArray(), line.toArray()); assert.equal(line2.length, line.length); - assert.equal(line2.isWrapped, line.isWrapped); }); it('should support combining chars', function(): void { // CHAR_DATA_CODE_INDEX resembles current behavior in InputHandler.print @@ -273,8 +273,6 @@ describe('BufferLine', function(): void { const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true); line2.copyFrom(line); assert.deepEqual(line2.toArray(), line.toArray()); - const line3 = line.clone(); - assert.deepEqual(TestBufferLine.prototype.toArray.apply(line3), line.toArray()); }); describe('resize', function(): void { it('enlarge(false)', function(): void { @@ -299,15 +297,13 @@ describe('BufferLine', function(): void { }); it('should remove combining data on replaced cells after shrinking then enlarging', () => { const line = new TestBufferLine(10, createCellData(1, 'a', 1), false); - line.set(2, [ 0, '😁', 1, '😁'.charCodeAt(0) ]); - line.set(9, [ 0, '😁', 1, '😁'.charCodeAt(0) ]); + line.setCell(2, createCellData(0, '😁', 1)); + line.setCell(9, createCellData(0, '😁', 1)); assert.equal(line.translateToString(), 'aa😁aaaaaa😁'); - assert.equal(Object.keys(line.combined).length, 2); line.resize(5, createCellData(1, 'a', 1)); assert.equal(line.translateToString(), 'aa😁aa'); line.resize(10, createCellData(1, 'a', 1)); assert.equal(line.translateToString(), 'aa😁aaaaaaa'); - assert.equal(Object.keys(line.combined).length, 1); }); }); describe('getTrimLength', function(): void { @@ -766,13 +762,6 @@ describe('BufferLine', function(): void { // no eAttrs again cell.bg &= ~BgFlags.HAS_EXTENDED; line.setCell(4, cell); - - const nLine = line.clone(); - assert.equal(extendedAttributes(nLine, 0), extendedAttributes(line, 0)); - assert.equal(extendedAttributes(nLine, 1), extendedAttributes(line, 1)); - assert.equal(extendedAttributes(nLine, 2), extendedAttributes(line, 2)); - assert.equal(extendedAttributes(nLine, 3), extendedAttributes(line, 3)); - assert.equal(extendedAttributes(nLine, 4), extendedAttributes(line, 4)); }); it('copyFrom', () => { const initial = new TestBufferLine(5); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index b7921942c8..ab928eb378 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -6,7 +6,7 @@ import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; -import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; +import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; /** @@ -21,6 +21,18 @@ import { stringFromCodePoint } from 'common/input/TextDecoder'; /** typed array slots taken by one cell */ const CELL_SIZE = 3; +/** Column count within current visible BufferLine(row). + * The left-most column is column 0. + */ +type BufferColumn = number; + +/** Column count within current LogicalLine. + * If the display is 80 columns wide, then LineColumn of the left-most + * character of the first wrapped line would normally be 80. + * (It might be 79 if the character at column 79 is double-width.) + */ +type LogicalColumn = number; + /** * Cell member indices. * @@ -37,13 +49,249 @@ const enum Cell { export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); -// Work variables to avoid garbage collection -let $startIndex = 0; +// Work variable to avoid garbage collection const $workCell = new CellData(); /** Factor when to cleanup underlying array buffer after shrinking. */ const CLEANUP_THRESHOLD = 2; +/* + * The data "model" of a line ignoring line wrapping. + */ +export class LogicalLine { + /** + * @internal + */ + public _data: Uint32Array; + + /** + * @internal + */ + public _combined: {[index: LogicalColumn]: string} = {}; + + /** + * @internal + */ + public _extendedAttrs: {[index: LogicalColumn]: IExtendedAttrs | undefined} = {}; + + public reflowNeeded: boolean = false; + public firstBufferLine: BufferLine | undefined; + public backgroundColor: number = 0; + /** + * Logical "trimmed" length of line. + * Must be no more than this._data.length / 3. */ + public length: number = 0; + + constructor(cols: number = 0, data = new Uint32Array(cols * CELL_SIZE)) { + this._data = data; + } + + /** + * @internal + */ + public resizeData(cols: number): void { + const uint32Cells = cols * CELL_SIZE; + const oldByteLength = this._data.buffer.byteLength; + const neededByteLength = uint32Cells * 4; + if (oldByteLength >= neededByteLength) { + // optimization: avoid alloc and data copy if buffer has enough room + this._data = new Uint32Array(this._data.buffer, 0, uint32Cells); + } else { + // slow path: new alloc and full data copy + const buffer = new ArrayBuffer(Math.max(12 + neededByteLength, (3 * oldByteLength) >> 1)); + const data = new Uint32Array(buffer, 0, uint32Cells); + data.set(this._data); + this._data = data; + } + } + + public getWidth(index: LogicalColumn): number { + return index >= this.length ? NULL_CELL_WIDTH + : this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT; + } + + /** usually same as argument, but adjust if wide or at end. + * @internal + */ + public charStart(column: LogicalColumn): number { + return column > this.length ? this.length + : column > 0 && this.getWidth(column - 1) > 1 ? column - 1 + : column; + } + + /** + * Load data at `index` into `cell`. + */ + public loadCell(index: LogicalColumn, cell: ICellData): ICellData { + if (index >= this.length) { + cell.content = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + cell.fg = 0; + cell.bg = this.backgroundColor; + return cell; + } + const startIndex = index * CELL_SIZE; + cell.content = this._data[startIndex + Cell.CONTENT]; + cell.fg = this._data[startIndex + Cell.FG]; + cell.bg = this._data[startIndex + Cell.BG]; + if (cell.content & Content.IS_COMBINED_MASK) { + cell.combinedData = this._combined[index]; + } + if (cell.bg & BgFlags.HAS_EXTENDED) { + cell.extended = this._extendedAttrs[index]!; + } + return cell; + } + + /** + * Set data at `index` to `cell`. + */ + public setCell(index: LogicalColumn, cell: ICellData): void { + const content = cell.content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); + this.setCellFromCodepoint(index, content, cell.getWidth(), cell); + if (cell.content & Content.IS_COMBINED_MASK) { + this._combined[index] = cell.combinedData; + } + } + + /** + * Set cell data from input handler. + * Since the input handler see the incoming chars as UTF32 codepoints, + * it gets an optimized access method. + */ + public setCellFromCodepoint(index: LogicalColumn, codePoint: number, width: number, attrs: IAttributeData): void { + if (codePoint === 0 && width === 1 && index >= this.length - 1 && attrs.fg === 0 && attrs.bg === this.backgroundColor) { + if (index === this.length - 1) { + // FIXME should also truncate extendedAttrs and composedData + this.length = index; // this.length - 1; + this.trimLength(); + } + return; + } + if (index >= this.length) { + if ((this as any).xyz) { console.log('-set fill '+index+' to '+this.length);} + this.resizeData(index + 1); + for (let i = this.length; i < index; i++) { + this._data[i * CELL_SIZE + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + this._data[i * CELL_SIZE + Cell.FG] = 0; + this._data[i * CELL_SIZE + Cell.BG] = this.backgroundColor; + } + this.length = index + 1; + } + if (attrs.bg & BgFlags.HAS_EXTENDED) { + this._extendedAttrs[index] = attrs.extended; + } + this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); + this._data[index * CELL_SIZE + Cell.FG] = attrs.fg; + this._data[index * CELL_SIZE + Cell.BG] = attrs.bg; + } + + /** + * Cleanup underlying array buffer. + * A cleanup will be triggered if the array buffer exceeds the actual used + * memory by a factor of CLEANUP_THRESHOLD. + * Returns 0 or 1 indicating whether a cleanup happened. + */ + public cleanupMemory(threshold: number = 1.3): number { + const cols = this.length; + if (cols * CELL_SIZE * 4 * threshold < this._data.buffer.byteLength) { + const data = new Uint32Array(CELL_SIZE * cols); + data.set(this._data); + this._data = data; + // Remove any cut off combined data + const keys = Object.keys(this._combined); + for (let i = 0; i < keys.length; i++) { + const key = parseInt(keys[i], 10); + if (key >= cols) { + delete this._combined[key]; + } + } + // remove any cut off extended attributes + const extKeys = Object.keys(this._extendedAttrs); + for (let i = 0; i < extKeys.length; i++) { + const key = parseInt(extKeys[i], 10); + if (key >= cols) { + delete this._extendedAttrs[key]; + } + } + return 1; + } + return 0; + } + + /** + * @internal + * + */ + public trimLength(): void { + let index = this.length; + while (index > 0) { + index--; + const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + if (content & Content.HAS_CONTENT_MASK) { + index++; + break; + } + } + if (index < this.length) { + this.length = index; + for (let line = this.firstBufferLine; line; line = line.nextBufferLine) { + if (line.startColumn > index) { + line.startColumn = index; + } + } + // FIXME - possible optimization - trim _data _combinedData _extendedAttrs + } + } + + public copyCellsFrom(src: LogicalLine, srcCol: number, dstCol: number, length: number, applyInReverse: boolean): void { + let cell = applyInReverse ? length - 1 : 0; + const cellIncrement = applyInReverse ? -1 : 1; + for (let todo = length; --todo >= 0; cell += cellIncrement) { + src.loadCell(srcCol + cell, $workCell); + this.setCell(dstCol + cell, $workCell); + } + } + + /** + * Translates the buffer line to a string. + * + * @param startCol The column to start the string (0-based inclusive). + * @param endCol The column to end the string (0-based exclusive). + * @param dataLength ignore _data after dataLength + * @param outColumns if specified, this array will be filled with column numbers such that + * `returnedString[i]` is displayed at `outColumns[i]` column. `outColumns[returnedString.length]` + * is where the character following `returnedString` will be displayed. + * + * When a single cell is translated to multiple UTF-16 code units (e.g. surrogate pair) in the + * returned string, the corresponding entries in `outColumns` will have the same column number. + */ + public translateToString(startCol?: number, endCol?: number, dataLength: number = this.length, outColumns?: number[]): string { + startCol = startCol ?? 0; + endCol = endCol ?? this.length; + if (outColumns) { + outColumns.length = 0; + } + let result = ''; + while (startCol < endCol) { + const content = startCol >= dataLength ? 0 + : this._data[startCol * CELL_SIZE + Cell.CONTENT]; + const cp = content & Content.CODEPOINT_MASK; + const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; + result += chars; + if (outColumns) { + for (let i = 0; i < chars.length; ++i) { + outColumns.push(startCol); + } + } + startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 + } + if (outColumns) { + outColumns.push(startCol); + } + return result; + } +} + /** * Typed array based bufferline implementation. * @@ -53,54 +301,67 @@ const CLEANUP_THRESHOLD = 2; * Used during normal input in `InputHandler` for faster buffer access. * - `setCell` * This method takes a CellData object and stores the data in the buffer. - * Use `CellData.fromCharData` to create the CellData object (e.g. from JS string). + * Use `CellData.fromCharData` to create the CellData object (e.g.0 f from JS string). * * To retrieve data from the buffer use either one of the primitive methods * (if only one particular value is needed) or `loadCell`. For `loadCell` in a loop * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ export class BufferLine implements IBufferLine { - protected _data: Uint32Array; - protected _combined: {[index: number]: string} = {}; - protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; + public logicalLine: LogicalLine; + public nextBufferLine: BufferLine | undefined; + + /** Number of logical columns in previous rows. + * Also: logical column number (column number assuming infinitely-wide + * terminal) corresponding to the start of this row. + * If R is the row number (0 for the first BufferLine for a LogicalLine), + * If R is 0 for the previous LogicalBufferLine, R is 1 for first + * then startColumn will *usually* be N*W (where W is the width of + * the terminal in columns) but may be slightly + * different when a wide character at column W-1 must wrap "early". + */ + public startColumn: number = 0; + public length: number; - private _isWrapped: boolean; - public get isWrapped(): boolean { - return this._isWrapped; - } /** + * Last LogicalColumn of this BufferLine. * @internal */ - public setWrapped(isWrapped: boolean): void { - this._isWrapped = isWrapped; + public get validEnd(): LogicalColumn { + return this.nextBufferLine ? this.nextBufferLine.startColumn : this.logicalLine.length; } - constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { - this._data = new Uint32Array(cols * CELL_SIZE); - const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); - for (let i = 0; i < cols; ++i) { - this.setCell(i, cell); - } + constructor(cols: number, logicalLine = new LogicalLine(cols)) { + this.logicalLine = logicalLine; this.length = cols; - this._isWrapped = isWrapped; + logicalLine.firstBufferLine ??= this; + } + + public get isWrapped(): boolean { + return this.logicalLine.firstBufferLine !== this; } /** * Get cell data CharData. * @deprecated */ - public get(index: number): CharData { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + public get(index: BufferColumn): CharData { + const lline = this.logicalLine; + const lindex: LogicalColumn = index + this.startColumn; + if (lindex >= this.validEnd) { + return [0, '', NULL_CELL_WIDTH, 0]; + } + const content = lline._data[index * CELL_SIZE + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; return [ - this._data[index * CELL_SIZE + Cell.FG], + lline._data[lindex * CELL_SIZE + Cell.FG], (content & Content.IS_COMBINED_MASK) - ? this._combined[index] + ? lline._combined[lindex] : (cp) ? stringFromCodePoint(cp) : '', content >> Content.WIDTH_SHIFT, (content & Content.IS_COMBINED_MASK) - ? this._combined[index].charCodeAt(this._combined[index].length - 1) + ? lline._combined[lindex].charCodeAt(lline._combined[lindex].length - 1) : cp ]; } @@ -110,13 +371,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { - this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; - if (value[CHAR_DATA_CHAR_INDEX].length > 1) { - this._combined[index] = value[1]; - this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); - } else { - this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); - } + this.setCell(index, CellData.fromCharData(value)); } /** @@ -124,22 +379,29 @@ export class BufferLine implements IBufferLine { * use these when only one value is needed, otherwise use `loadCell` */ public getWidth(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT; + const lindex: LogicalColumn = index + this.startColumn; + return lindex >= this.validEnd ? NULL_CELL_WIDTH + : this.logicalLine.getWidth(lindex); } /** Test whether content has width. */ public hasWidth(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK; + return this.getWidth(index); } /** Get FG cell component. */ public getFg(index: number): number { - return this._data[index * CELL_SIZE + Cell.FG]; + const lline = this.logicalLine; + const lcolumn = index + this.startColumn; + return lcolumn >= this.validEnd ? 0 : lline._data[lcolumn * CELL_SIZE + Cell.FG]; } /** Get BG cell component. */ public getBg(index: number): number { - return this._data[index * CELL_SIZE + Cell.BG]; + index += this.startColumn; + const lline = this.logicalLine; + return index > lline.length ? lline.backgroundColor + : lline._data[index * CELL_SIZE + Cell.BG]; } /** @@ -148,7 +410,12 @@ export class BufferLine implements IBufferLine { * from real empty cells. */ public hasContent(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK; + index += this.startColumn; + if (index >= this.validEnd) { + return 0; + } + const lline = this.logicalLine; + return lline._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** @@ -156,24 +423,40 @@ export class BufferLine implements IBufferLine { * To be in line with `code` in CharData this either returns * a single UTF32 codepoint or the last codepoint of a combined string. */ - public getCodePoint(index: number): number { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + public getCodePoint(index: BufferColumn): number { + const lline = this.logicalLine; + const lcolumn: LogicalColumn = index + this.startColumn; + if (lcolumn >= this.validEnd) { + return 0; + } + const content = lline._data[lcolumn * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { - return this._combined[index].charCodeAt(this._combined[index].length - 1); + const combined = lline._combined[lcolumn]; + return combined.charCodeAt(combined.length - 1); } return content & Content.CODEPOINT_MASK; } /** Test whether the cell contains a combined string. */ public isCombined(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK; + const lline = this.logicalLine; + const lcolumn: LogicalColumn = index + this.startColumn; + if (lcolumn >= this.validEnd) { + return 0; + } + return lline._data[lcolumn * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ public getString(index: number): string { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + const lline = this.logicalLine; + const lcolumn: LogicalColumn = index + this.startColumn; + if (lcolumn >= this.validEnd) { + return ''; + } + const content = lline._data[lcolumn * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { - return this._combined[index]; + return lline._combined[lcolumn]; } if (content & Content.CODEPOINT_MASK) { return stringFromCodePoint(content & Content.CODEPOINT_MASK); @@ -184,7 +467,10 @@ export class BufferLine implements IBufferLine { /** Get state of protected flag. */ public isProtected(index: number): number { - return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED; + const lline = this.logicalLine; + const lcolumn = index + this.startColumn; + return index >= this.length || lcolumn >= lline.length ? 0 + : lline._data[lcolumn * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED; } /** @@ -192,32 +478,32 @@ export class BufferLine implements IBufferLine { * to GC as it significantly reduced the amount of new objects/references needed. */ public loadCell(index: number, cell: ICellData): ICellData { - $startIndex = index * CELL_SIZE; - cell.content = this._data[$startIndex + Cell.CONTENT]; - cell.fg = this._data[$startIndex + Cell.FG]; - cell.bg = this._data[$startIndex + Cell.BG]; - if (cell.content & Content.IS_COMBINED_MASK) { - cell.combinedData = this._combined[index]; - } - if (cell.bg & BgFlags.HAS_EXTENDED) { - cell.extended = this._extendedAttrs[index]!; + const lline = this.logicalLine; + const lcolumn = index + this.startColumn; + const lend = this.validEnd; + if (lcolumn >= lend) { + cell.content = NULL_CELL_CODE | (NULL_CELL_WIDTH << Content.WIDTH_SHIFT); + cell.fg = 0; + if (this.nextBufferLine) { + cell.bg = 0; // FIXME + } else { + cell.bg = lline.backgroundColor; + } + return cell; } - return cell; + return lline.loadCell(lcolumn, cell); } /** * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { + // this.logicalLine.setCell(index + this.startColumn, cell); + const content = cell.content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); + this.setCellFromCodepoint(index, content, cell.getWidth(), cell); if (cell.content & Content.IS_COMBINED_MASK) { - this._combined[index] = cell.combinedData; + this.logicalLine._combined[index + this.startColumn] = cell.combinedData; } - if (cell.bg & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[index] = cell.extended; - } - this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content; - this._data[index * CELL_SIZE + Cell.FG] = cell.fg; - this._data[index * CELL_SIZE + Cell.BG] = cell.bg; } /** @@ -226,12 +512,8 @@ export class BufferLine implements IBufferLine { * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { - if (attrs.bg & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[index] = attrs.extended; - } - this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); - this._data[index * CELL_SIZE + Cell.FG] = attrs.fg; - this._data[index * CELL_SIZE + Cell.BG] = attrs.bg; + this.logicalLine.setCellFromCodepoint(index + this.startColumn, + codePoint, width, attrs); } /** @@ -241,16 +523,24 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { - let content = this._data[index * CELL_SIZE + Cell.CONTENT]; + const lline = this.logicalLine; + const lcolumn = index + this.startColumn; + if (lcolumn >= this.validEnd) { + // should not happen - we actually have no data in the cell yet + // simply set the data in the cell buffer with a width of 1 + this.setCellFromCodepoint(index, codePoint, 1, CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE])); + return; + } + let content = lline._data[lcolumn * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add - this._combined[index] += stringFromCodePoint(codePoint); + lline._combined[lcolumn] += stringFromCodePoint(codePoint); } else { if (content & Content.CODEPOINT_MASK) { // normal case for combining chars: // - move current leading char + new one into combined string // - set combined flag - this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint); + lline._combined[lcolumn] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint); content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0 content |= Content.IS_COMBINED_MASK; } else { @@ -263,7 +553,7 @@ export class BufferLine implements IBufferLine { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; } - this._data[index * CELL_SIZE + Cell.CONTENT] = content; + lline._data[lcolumn * CELL_SIZE + Cell.CONTENT] = content; } public insertCells(pos: number, n: number, fillCellData: ICellData): void { @@ -352,52 +642,50 @@ export class BufferLine implements IBufferLine { } /** - * Resize BufferLine to `cols` filling excess cells with `fillCellData`. + * Resize to `cols` filling excess cells with `fillCellData`. * The underlying array buffer will not change if there is still enough space * to hold the new buffer line data. * Returns a boolean indicating, whether a `cleanupMemory` call would free * excess memory (true after shrinking > CLEANUP_THRESHOLD). + * Assumes single unwrapped line. + * @deprecated only used in tests */ public resize(cols: number, fillCellData: ICellData): boolean { + const logical = this.logicalLine; + if (logical.firstBufferLine !== this || this.nextBufferLine) { + throw new Error('invalid call to resize'); + } if (cols === this.length) { - return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return logical._data.length * 4 * CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } const uint32Cells = cols * CELL_SIZE; if (cols > this.length) { - if (this._data.buffer.byteLength >= uint32Cells * 4) { - // optimization: avoid alloc and data copy if buffer has enough room - this._data = new Uint32Array(this._data.buffer, 0, uint32Cells); - } else { - // slow path: new alloc and full data copy - const data = new Uint32Array(uint32Cells); - data.set(this._data); - this._data = data; - } + logical.resizeData(cols); for (let i = this.length; i < cols; ++i) { this.setCell(i, fillCellData); } } else { // optimization: just shrink the view on existing buffer - this._data = this._data.subarray(0, uint32Cells); + logical._data = logical._data.subarray(0, cols * CELL_SIZE); // Remove any cut off combined data - const keys = Object.keys(this._combined); + const keys = Object.keys(logical._combined); for (let i = 0; i < keys.length; i++) { const key = parseInt(keys[i], 10); if (key >= cols) { - delete this._combined[key]; + delete logical._combined[key]; } } // remove any cut off extended attributes - const extKeys = Object.keys(this._extendedAttrs); + const extKeys = Object.keys(logical._extendedAttrs); for (let i = 0; i < extKeys.length; i++) { const key = parseInt(extKeys[i], 10); if (key >= cols) { - delete this._extendedAttrs[key]; + delete logical._extendedAttrs[key]; } } } this.length = cols; - return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return uint32Cells * 4 * CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } /** @@ -407,13 +695,7 @@ export class BufferLine implements IBufferLine { * Returns 0 or 1 indicating whether a cleanup happened. */ public cleanupMemory(): number { - if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) { - const data = new Uint32Array(this._data.length); - data.set(this._data); - this._data = data; - return 1; - } - return 0; + return this.logicalLine.cleanupMemory(CLEANUP_THRESHOLD); } /** fill a line with fillCharData */ @@ -427,96 +709,48 @@ export class BufferLine implements IBufferLine { } return; } - this._combined = {}; - this._extendedAttrs = {}; + const lline = this.logicalLine; + if (lline.firstBufferLine === this && ! this.nextBufferLine) { + lline._combined = {}; + lline._extendedAttrs = {}; + } for (let i = 0; i < this.length; ++i) { this.setCell(i, fillCellData); } } - /** alter to a full copy of line */ + /** alter to a full copy of line + * @deprecated only used in a few tests + */ public copyFrom(line: BufferLine): void { - if (this.length !== line.length) { - this._data = new Uint32Array(line._data); - } else { - // use high speed copy if lengths are equal - this._data.set(line._data); - } + this.copyCellsFrom(line, 0, 0, this.length, false); this.length = line.length; - this._combined = {}; - for (const el in line._combined) { - this._combined[el] = line._combined[el]; - } - this._extendedAttrs = {}; - for (const el in line._extendedAttrs) { - this._extendedAttrs[el] = line._extendedAttrs[el]; - } - this._isWrapped = line._isWrapped; - } - - /** create a new clone */ - public clone(): IBufferLine { - const newLine = new BufferLine(0); - newLine._data = new Uint32Array(this._data); - newLine.length = this.length; - for (const el in this._combined) { - newLine._combined[el] = this._combined[el]; - } - for (const el in this._extendedAttrs) { - newLine._extendedAttrs[el] = this._extendedAttrs[el]; - } - newLine._isWrapped = this._isWrapped; - return newLine; } - public getTrimmedLength(): number { - for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) { - return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); + public getTrimmedLength(noBg: boolean = false): number { + const logicalLine = this.logicalLine; + const startColumn = this.startColumn; + const data = logicalLine._data; + for (let i = this.validEnd; --i >= startColumn; ) { + if ((data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) + || (noBg && (data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK))) { + i += data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT; + return i - startColumn; } } - return 0; + return startColumn; } public getNoBgTrimmedLength(): number { - for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) { - return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); - } + if (this.logicalLine.backgroundColor) { + return this.length; } - return 0; + return this.getTrimmedLength(true); } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { - const srcData = src._data; - if (applyInReverse) { - for (let cell = length - 1; cell >= 0; cell--) { - for (let i = 0; i < CELL_SIZE; i++) { - this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; - } - if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; - } - } - } else { - for (let cell = 0; cell < length; cell++) { - for (let i = 0; i < CELL_SIZE; i++) { - this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; - } - if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; - } - } - } - - // Move any combined data over as needed, FIXME: repeat for extended attrs - const srcCombinedKeys = Object.keys(src._combined); - for (let i = 0; i < srcCombinedKeys.length; i++) { - const key = parseInt(srcCombinedKeys[i], 10); - if (key >= srcCol) { - this._combined[key - srcCol + destCol] = src._combined[key]; - } - } + this.logicalLine.copyCellsFrom(src.logicalLine, srcCol + src.startColumn, + destCol + this.startColumn, length, applyInReverse); } /** @@ -538,25 +772,121 @@ export class BufferLine implements IBufferLine { if (trimRight) { endCol = Math.min(endCol, this.getTrimmedLength()); } - if (outColumns) { - outColumns.length = 0; + const lline = this.logicalLine; + const lineStart = this.startColumn; + const validEnd = this.validEnd; + startCol += lineStart; + endCol += lineStart; + const paddingNeeded = trimRight || endCol <= validEnd ? 0 + : endCol - validEnd; + const result = lline.translateToString(startCol, endCol, endCol - paddingNeeded, outColumns); + if (outColumns && lineStart) { + for (let i = outColumns.length; --i >= 0; ) { + outColumns[i] -= lineStart; + } } - let result = ''; - while (startCol < endCol) { - const content = this._data[startCol * CELL_SIZE + Cell.CONTENT]; - const cp = content & Content.CODEPOINT_MASK; - const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; - result += chars; - if (outColumns) { - for (let i = 0; i < chars.length; ++i) { - outColumns.push(startCol); + return result; + } + + public getPreviousLine(): BufferLine | undefined { + for (let row = this.logicalLine.firstBufferLine; ;) { + if (! row) { + return undefined; + } + const next = row.nextBufferLine; + if (next === this) { + return row; + } + row = next; + } + } + + public eraseRight(index: BufferColumn): void { + const lineStart = this.startColumn; + const lineEnd = lineStart + index; + const lline = this.logicalLine; + if (this.nextBufferLine) { + const oldEnd = this.nextBufferLine.startColumn; + const count = oldEnd - lineEnd; + if (count > 0) { + let next: BufferLine | undefined = this; + for (;;) { + next = next.nextBufferLine; + if (! next) break; + next.startColumn -= count; } + lline.copyCellsFrom(lline, oldEnd, lineEnd, lline.length - oldEnd, false); + lline.length -= count; + } + } else { + if (lineEnd < lline.length) { + lline.length = lineEnd; } - startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 } - if (outColumns) { - outColumns.push(startCol); + } + + public setWrapped(previousLine: BufferLine): BufferLine { + const column = previousLine.startColumn + previousLine.length; + const logicalLine = previousLine.logicalLine; + const oldLogical = this.logicalLine; + logicalLine.resizeData(column + oldLogical.length); + const newData = logicalLine._data; + for (let i = logicalLine.length; i < column + oldLogical.length; i++) { + newData[i * CELL_SIZE + Cell.CONTENT] = 0; + newData[i * CELL_SIZE + Cell.FG] = 0; + newData[i * CELL_SIZE + Cell.BG] = logicalLine.backgroundColor; + } + logicalLine.copyCellsFrom(oldLogical, 0, column, oldLogical.length, false); + /* + const oldData = oldLogical._data; + for (let i = 0; i < oldLogical.length; i++) { + const oldIndex = i * CELL_SIZE; + const newIndex = (column + i) * CELL_SIZE + const content = oldData[oldIndex + Cell.CONTENT]; + const fg = oldData[oldIndex + Cell.FG]; + const bg = oldData[oldIndex + Cell.BG]; + newData[newIndex + Cell.CONTENT] = content; + newData[newIndex + Cell.FG] = fg; + newData[newIndex + Cell.BG] = bg; + if (content & Content.IS_COMBINED_MASK) { + lprevious._combined[column + i] = oldLogical._combined[i]; + } + if (bg & BgFlags.HAS_EXTENDED) { + lprevious._extendedAttrs[column + i] = oldLogical._extendedAttrs[i]; + } } - return result; + */ + + logicalLine.length = column + oldLogical.length; + if ((globalThis as any).xyz) console.log('- llen='+column+'+'+oldLogical.length); + logicalLine.backgroundColor = oldLogical.backgroundColor; + previousLine.nextBufferLine = this; + for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { + line.startColumn += column; + line.logicalLine = logicalLine; + } + return this; + + } + + public asUnwrapped(prevRow: BufferLine): LogicalLine { + const oldStartColumn = this.startColumn; + prevRow.nextBufferLine = undefined; + const oldLine = prevRow.logicalLine; + const cell = new CellData(); + this.loadCell(oldStartColumn, cell); + const newLength = oldLine.length - oldStartColumn; + const newLogical = new LogicalLine(newLength); + newLogical.copyCellsFrom(oldLine, oldStartColumn, 0, newLength, false); + newLogical.firstBufferLine = this; + for (let nextRow: BufferLine | undefined = this; nextRow; nextRow = nextRow.nextBufferLine) { + nextRow.startColumn -= oldStartColumn; + nextRow.logicalLine = newLogical; + } + oldLine.length = oldStartColumn; + oldLine.trimLength(); + // FIXME truncate/resize + newLogical.backgroundColor = oldLine.backgroundColor; + return newLogical; } } diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index b351b89c42..422231c4e8 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -5,7 +5,71 @@ import { assert } from 'chai'; import { BufferLine } from 'common/buffer/BufferLine'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants'; -import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow'; + +/** + * Gets the new line lengths for a given wrapped line. The purpose of this function it to pre- + * compute the wrapping points since wide characters may need to be wrapped onto the following line. + * This function will return an array of numbers of where each line wraps to, the resulting array + * will only contain the values `newCols` (when the line does not end with a wide character) and + * `newCols - 1` (when the line does end with a wide character), except for the last value which + * will contain the remaining items to fill the line. + * + * Calling this with a `newCols` value of `1` will lock up. + * + * This function is now only used for testing. + * + * @param wrappedLines The wrapped lines to evaluate. + * @param oldCols The columns before resize. + * @param newCols The columns after resize. + */ +function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] { + const newLineLengths: number[] = []; + const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c); + + // Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and + // linesNeeded + let srcCol = 0; + let srcLine = 0; + let cellsAvailable = 0; + while (cellsAvailable < cellsNeeded) { + if (cellsNeeded - cellsAvailable < newCols) { + // Add the final line and exit the loop + newLineLengths.push(cellsNeeded - cellsAvailable); + break; + } + srcCol += newCols; + const oldTrimmedLength = getWrappedLineTrimmedLength(wrappedLines, srcLine, oldCols); + if (srcCol > oldTrimmedLength) { + srcCol -= oldTrimmedLength; + srcLine++; + } + const endsWithWide = wrappedLines[srcLine].getWidth(srcCol - 1) === 2; + if (endsWithWide) { + srcCol--; + } + const lineLength = endsWithWide ? newCols - 1 : newCols; + newLineLengths.push(lineLength); + cellsAvailable += lineLength; + } + + return newLineLengths; +} + +function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number { + // If this is the last row in the wrapped line, get the actual trimmed length + if (i === lines.length - 1) { + return lines[i].getTrimmedLength(); + } + // Detect whether the following line starts with a wide character and the end of the current line + // is null, if so then we can be pretty sure the null character should be excluded from the line + // length] + const endsInNull = !(lines[i].hasContent(cols - 1)) && lines[i].getWidth(cols - 1) === 1; + const followingLineStartsWithWide = lines[i + 1].getWidth(0) === 2; + if (endsInNull && followingLineStartsWithWide) { + return cols - 1; + } + return cols; +} describe('BufferReflow', () => { describe('reflowSmallerGetNewLineLengths', () => { @@ -63,7 +127,7 @@ describe('BufferReflow', () => { line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]); line1.set(4, [0, '', 0, 0]); line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]); - const line2 = new BufferLine(6, undefined, true); + const line2 = new BufferLine(6, undefined); line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line2.set(2, [0, '', 0, 0]); diff --git a/src/common/buffer/BufferReflow.ts b/src/common/buffer/BufferReflow.ts index 44aa0976fe..9822ae261d 100644 --- a/src/common/buffer/BufferReflow.ts +++ b/src/common/buffer/BufferReflow.ts @@ -5,109 +5,13 @@ import { BufferLine } from 'common/buffer/BufferLine'; import { CircularList } from 'common/CircularList'; -import { IBufferLine, ICellData } from 'common/Types'; +import { IBufferLine } from 'common/Types'; export interface INewLayoutResult { layout: number[]; countRemoved: number; } -/** - * Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed - * when a wrapped line unwraps. - * @param lines The buffer lines. - * @param oldCols The columns before resize - * @param newCols The columns after resize. - * @param bufferAbsoluteY The absolute y position of the cursor (baseY + cursorY). - * @param nullCell The cell data to use when filling in empty cells. - * @param reflowCursorLine Whether to reflow the line containing the cursor. - */ -export function reflowLargerGetLinesToRemove(lines: CircularList, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] { - // Gather all BufferLines that need to be removed from the Buffer here so that they can be - // batched up and only committed once - const toRemove: number[] = []; - - for (let y = 0; y < lines.length - 1; y++) { - // Check if this row is wrapped - let i = y; - let nextLine = lines.get(++i) as BufferLine; - if (!nextLine.isWrapped) { - continue; - } - - // Check how many lines it's wrapped for - const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine]; - while (i < lines.length && nextLine.isWrapped) { - wrappedLines.push(nextLine); - nextLine = lines.get(++i) as BufferLine; - } - - if (!reflowCursorLine) { - // If these lines contain the cursor don't touch them, the program will handle fixing up - // wrapped lines with the cursor - if (bufferAbsoluteY >= y && bufferAbsoluteY < i) { - y += wrappedLines.length - 1; - continue; - } - } - - // Copy buffer data to new locations - let destLineIndex = 0; - let destCol = getWrappedLineTrimmedLength(wrappedLines, destLineIndex, oldCols); - let srcLineIndex = 1; - let srcCol = 0; - while (srcLineIndex < wrappedLines.length) { - const srcTrimmedTineLength = getWrappedLineTrimmedLength(wrappedLines, srcLineIndex, oldCols); - const srcRemainingCells = srcTrimmedTineLength - srcCol; - const destRemainingCells = newCols - destCol; - const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells); - - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false); - - destCol += cellsToCopy; - if (destCol === newCols) { - destLineIndex++; - destCol = 0; - } - srcCol += cellsToCopy; - if (srcCol === srcTrimmedTineLength) { - srcLineIndex++; - srcCol = 0; - } - - // Make sure the last cell isn't wide, if it is copy it to the current dest - if (destCol === 0 && destLineIndex !== 0) { - if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) { - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false); - // Null out the end of the last row - wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell); - } - } - } - - // Clear out remaining cells or fragments could remain; - wrappedLines[destLineIndex].replaceCells(destCol, newCols, nullCell); - - // Work backwards and remove any rows at the end that only contain null cells - let countToRemove = 0; - for (let i = wrappedLines.length - 1; i > 0; i--) { - if (i > destLineIndex || wrappedLines[i].getTrimmedLength() === 0) { - countToRemove++; - } else { - break; - } - } - - if (countToRemove > 0) { - toRemove.push(y + wrappedLines.length - countToRemove); // index - toRemove.push(countToRemove); - } - - y += wrappedLines.length - 1; - } - return toRemove; -} - /** * Creates and return the new layout for lines given an array of indexes to be removed. * @param lines The buffer lines. @@ -162,53 +66,6 @@ export function reflowLargerApplyNewLayout(lines: CircularList, new lines.length = newLayout.length; } -/** - * Gets the new line lengths for a given wrapped line. The purpose of this function it to pre- - * compute the wrapping points since wide characters may need to be wrapped onto the following line. - * This function will return an array of numbers of where each line wraps to, the resulting array - * will only contain the values `newCols` (when the line does not end with a wide character) and - * `newCols - 1` (when the line does end with a wide character), except for the last value which - * will contain the remaining items to fill the line. - * - * Calling this with a `newCols` value of `1` will lock up. - * - * @param wrappedLines The wrapped lines to evaluate. - * @param oldCols The columns before resize. - * @param newCols The columns after resize. - */ -export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] { - const newLineLengths: number[] = []; - const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c); - - // Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and - // linesNeeded - let srcCol = 0; - let srcLine = 0; - let cellsAvailable = 0; - while (cellsAvailable < cellsNeeded) { - if (cellsNeeded - cellsAvailable < newCols) { - // Add the final line and exit the loop - newLineLengths.push(cellsNeeded - cellsAvailable); - break; - } - srcCol += newCols; - const oldTrimmedLength = getWrappedLineTrimmedLength(wrappedLines, srcLine, oldCols); - if (srcCol > oldTrimmedLength) { - srcCol -= oldTrimmedLength; - srcLine++; - } - const endsWithWide = wrappedLines[srcLine].getWidth(srcCol - 1) === 2; - if (endsWithWide) { - srcCol--; - } - const lineLength = endsWithWide ? newCols - 1 : newCols; - newLineLengths.push(lineLength); - cellsAvailable += lineLength; - } - - return newLineLengths; -} - export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number { // If this is the last row in the wrapped line, get the actual trimmed length if (i === lines.length - 1) { diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 43c4c594b0..892e2d7f36 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -88,7 +88,9 @@ export class CellData extends AttributeData implements ICellData { this.content = Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } } - /** Get data as CharData. */ + /** Get data as CharData. + * @deprecated + */ public getAsCharData(): CharData { return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; } diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index 5ce075cf78..b37c0f3a81 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -4,6 +4,7 @@ */ export const DEFAULT_COLOR = 0; +// Only used for testing - move to TestUtils? export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); export const DEFAULT_EXT = 0; diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index a7e7321f2f..11e180ff0b 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -11,10 +11,30 @@ export type BufferIndex = [number, number]; export interface IBuffer { readonly lines: ICircularList; + /** Number of rows above top visible row. + * Similar to scrollTop (i.e. affected by scrollbar), but in rows. + */ ydisp: number; + /** Number of rows in the scrollback buffer, above the home row. */ ybase: number; + + /** Row number relative to the "home" row, zero-origin. + * This is the row number changed/reported by cursor escape sequences, + * except that y is 0-origin: y=0 when we're at the home row. + * Currently assumed to be >= 0, but future may allow negative - i.e. + * in scroll-back area, as long as ybase+y >= 0. + */ y: number; + + /** Column number, zero-origin. + * Valid range is 0 through C (inclusive), if C is terminal width in columns. + * The first (left-most) column is 0. + * The right-most column is either C-1 (before the right-most column, and + * ready to write in it), or C (after the right-most column, having written + * to it, and ready to wrap). DSR 6 returns C (1-origin) in either case, + */ x: number; + tabs: any; scrollBottom: number; scrollTop: number; diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index e71bc04ab1..da3bbecaeb 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -6,10 +6,10 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine } from 'common/Types'; import { BufferSet } from 'common/buffer/BufferSet'; -import { BufferLine } from 'common/buffer/BufferLine'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; import { Emitter } from 'common/Event'; +import { BufferLine, LogicalLine } from 'common/buffer/BufferLine'; export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars export const MINIMUM_ROWS = 1; @@ -67,17 +67,21 @@ export class BufferService extends Disposable implements IBufferService { */ public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void { const buffer = this.buffer; - - let newLine: IBufferLine | undefined; - newLine = this._cachedBlankLine; - if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) { - newLine = buffer.getBlankLine(eraseAttr, isWrapped); - this._cachedBlankLine = newLine; - } - (newLine as BufferLine).setWrapped(isWrapped); - const topRow = buffer.ybase + buffer.scrollTop; const bottomRow = buffer.ybase + buffer.scrollBottom; + const oldLine = buffer.lines.get(bottomRow) as BufferLine; + let lline: LogicalLine; + if (isWrapped) { + lline = oldLine.logicalLine; + } else { + lline = new LogicalLine(0); + } + const newLine = new BufferLine(this.cols, lline); + if (isWrapped && oldLine) { + oldLine.nextBufferLine = newLine; + newLine.startColumn = lline.length; + } + lline.backgroundColor = eraseAttr.bg; if (buffer.scrollTop === 0) { // Determine whether the buffer is going to be trimmed after insertion. @@ -85,13 +89,9 @@ export class BufferService extends Disposable implements IBufferService { // Insert the line using the fastest method if (bottomRow === buffer.lines.length - 1) { - if (willBufferBeTrimmed) { - buffer.lines.recycle().copyFrom(newLine); - } else { - buffer.lines.push(newLine.clone()); - } + buffer.lines.push(newLine); } else { - buffer.lines.splice(bottomRow + 1, 0, newLine.clone()); + buffer.lines.splice(bottomRow + 1, 0, newLine); } // Only adjust ybase and ydisp when the buffer is not trimmed @@ -113,7 +113,7 @@ export class BufferService extends Disposable implements IBufferService { // scrollback, instead we can just shift them in-place. const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); - buffer.lines.set(bottomRow, newLine.clone()); + buffer.lines.set(bottomRow, newLine); } // Move the viewport to the bottom of the buffer unless the user is From e7d9acdda612b2ba573b39b5e118215b171b71a1 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 28 Apr 2026 09:46:43 -0700 Subject: [PATCH 04/20] Proof-of-concept attaching markers and decorations to LogicalLine. --- src/browser/CoreBrowserTerminal.ts | 4 + .../renderer/dom/DomRendererRowFactory.ts | 9 +- src/common/TestUtils.test.ts | 2 + src/common/Types.ts | 7 ++ src/common/buffer/Buffer.test.ts | 5 +- src/common/buffer/Buffer.ts | 90 +++++++++--------- src/common/buffer/BufferLine.ts | 40 ++++++-- src/common/buffer/Marker.ts | 91 ++++++++++++++++++- src/common/services/DecorationService.ts | 25 ++++- src/common/services/Services.ts | 4 +- typings/xterm.d.ts | 4 + 11 files changed, 215 insertions(+), 66 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 52eb000256..0dbc1b0db5 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -763,6 +763,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } } + /** + * This is somewhat expensive operation. + * @deprecated + */ public get markers(): IMarker[] { return this.buffer.markers; } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 0cff9c25fd..540a9bf91c 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -13,6 +13,7 @@ import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'bro import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; import { AttributeData } from 'common/buffer/AttributeData'; +import { BufferLine } from 'common/buffer/BufferLine'; import { WidthCache } from 'browser/renderer/dom/WidthCache'; import { IColorContrastCache } from 'browser/Types'; @@ -168,9 +169,9 @@ export class DomRendererRowFactory { } let isDecorated = false; - this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { + this._decorationService.forEachDecorationAtCellLine(x, row, undefined, d => { isDecorated = true; - }); + }, lineData as BufferLine); // get chars to render for this cell let chars = cell.getChars() || WHITESPACE_CELL_CHAR; @@ -358,7 +359,7 @@ export class DomRendererRowFactory { let bgOverride: IColor | undefined; let fgOverride: IColor | undefined; let isTop = false; - this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { + this._decorationService.forEachDecorationAtCellLine(x, row, undefined, d => { if (d.options.layer !== 'top' && isTop) { return; } @@ -373,7 +374,7 @@ export class DomRendererRowFactory { fgOverride = d.foregroundColorRGB; } isTop = d.options.layer === 'top'; - }); + }, lineData as BufferLine); // Apply selection if (!isTop && isInSelection) { diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 5940fa6596..bf0d643669 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -14,6 +14,7 @@ import { UnicodeV6 } from 'common/input/UnicodeV6'; import { IDecorationOptions, IDecoration } from '@xterm/xterm'; import { Emitter, type IEvent } from 'common/Event'; import { CellData } from 'common/buffer/CellData'; +import { BufferLine } from 'common/buffer/BufferLine'; import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from 'common/buffer/Constants'; export function createCellData(attr: number, char: string, width: number): CellData { @@ -239,5 +240,6 @@ export class MockDecorationService implements IDecorationService { public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { return undefined; } public reset(): void { } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { } + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: BufferLine): void { } public dispose(): void { } } diff --git a/src/common/Types.ts b/src/common/Types.ts index 073f964913..bf90c2d07c 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -253,10 +253,17 @@ export interface IBufferLine { } export interface IMarker extends IDisposable { + /** + * @deprecated + */ readonly id: number; readonly isDisposed: boolean; + /** + * @deprecated + */ readonly line: number; onDispose: IEvent; + payload?: IDisposable; } export interface IModes { insertMode: boolean; diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index fb6eadd83d..4fded177d8 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -454,8 +454,8 @@ describe('Buffer', () => { assert.equal(buffer.lines.get(1)!.translateToString(), '0123456789'); assert.equal(buffer.lines.get(2)!.translateToString(), 'klmnopqrst'); assert.equal(firstMarker.line, 0, 'first marker should remain unchanged'); - assert.equal(secondMarker.line, 1, 'second marker should be restored to it\'s original line'); - assert.equal(thirdMarker.line, 2, 'third marker should be restored to it\'s original line'); + assert.equal(secondMarker.line, 1, 'second marker should be restored to its original line'); + assert.equal(thirdMarker.line, 2, 'third marker should be restored to its original line'); assert.equal(firstMarker.isDisposed, false); assert.equal(secondMarker.isDisposed, false); assert.equal(thirdMarker.isDisposed, false); @@ -494,7 +494,6 @@ describe('Buffer', () => { assert.equal(firstMarker.line, 0); assert.equal(secondMarker.line, 1); assert.equal(thirdMarker.line, 2); - buffer.resize(2, 11); assert.equal(buffer.lines.get(0)!.translateToString(), 'ij'); assert.equal(buffer.lines.get(1)!.translateToString(), '01'); assert.equal(buffer.lines.get(2)!.translateToString(), '23'); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index cc322c641c..690eccff4e 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * Copyright (c) 2017 The xterm.js authors. All rsavedights reserved. * @license MIT */ @@ -42,7 +42,24 @@ export class Buffer implements IBuffer { public savedGlevel: number = 0; public savedOriginMode: boolean = false; public savedWraparoundMode: boolean = true; - public markers: Marker[] = []; + /** + * This is an expensive operation. + * @deprecated + */ + public get markers(): Marker[] { + const mm: Marker[] = []; + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + const bline = this.lines.get(i) as BufferLine; + const lline = bline.logicalLine; + if (lline.firstBufferLine === bline) { + for (let m = lline._firstMarker; m; m = m._nextMarker) { + mm.push(m); + } + } + } + return mm; + } private _nullCell: ICellData = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); private _whitespaceCell: ICellData = CellData.fromCharData([0, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_WIDTH, WHITESPACE_CELL_CODE]); private _cols: number; @@ -542,16 +559,13 @@ export class Buffer implements IBuffer { } } - // Update markers - let insertCountEmitted = 0; - for (let i = insertEvents.length - 1; i >= 0; i--) { - insertEvents[i].index += insertCountEmitted; - this.lines.onInsertEmitter.fire(insertEvents[i]); - insertCountEmitted += insertEvents[i].amount; - } const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); if (amountToTrim > 0) { - this.lines.onTrimEmitter.fire(amountToTrim); + /* + for (let i = 0; i < amountToTrim; i++) { + this.clearMarkers(i); + } + */ } } } @@ -633,10 +647,13 @@ export class Buffer implements IBuffer { */ public clearMarkers(y: number): void { this._isClearing = true; - for (let i = 0; i < this.markers.length; i++) { - if (this.markers[i].line === y) { - this.markers[i].dispose(); - this.markers.splice(i--, 1); + const bline = this.lines.get(y) as BufferLine; + const startColumn = bline.startColumn; + const endColumn = bline.nextBufferLine ? bline.nextBufferLine.startColumn : Infinity; + const lline = bline.logicalLine; + for (let m = lline._firstMarker; m; m = m._nextMarker) { + if (m._startColumn >= startColumn && m._startColumn < endColumn) { + m.dispose(); } } this._isClearing = false; @@ -644,49 +661,30 @@ export class Buffer implements IBuffer { /** * Clears markers on all lines + * Must be called before removing lines from Buffer. + * Only used for the alt buffer, which should be small. */ public clearAllMarkers(): void { this._isClearing = true; - for (let i = 0; i < this.markers.length; i++) { - this.markers[i].dispose(); + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + this.clearMarkers(i); } - this.markers.length = 0; this._isClearing = false; } - public addMarker(y: number): Marker { - const marker = new Marker(y); - this.markers.push(marker); - marker.register(this.lines.onTrim(amount => { - marker.line -= amount; - // The marker should be disposed when the line is trimmed from the buffer - if (marker.line < 0) { - marker.dispose(); - } - })); - marker.register(this.lines.onInsert(event => { - if (marker.line >= event.index) { - marker.line += event.amount; - } - })); - marker.register(this.lines.onDelete(event => { - // Delete the marker if it's within the range - if (marker.line >= event.index && marker.line < event.index + event.amount) { - marker.dispose(); - } - - // Shift the marker if it's after the deleted range - if (marker.line > event.index) { - marker.line -= event.amount; - } - })); - marker.register(marker.onDispose(() => this._removeMarker(marker))); - return marker; + public addMarker(y: number, x?: number, marker?: Marker): Marker { + const bline = this.lines.get(y) as BufferLine; + const lline = bline.logicalLine; + const m = marker ?? new Marker(); + m.addToLine(this, lline, x ?? bline.startColumn); + m.register(m.onDispose(() => this._removeMarker(m))); + return m; } private _removeMarker(marker: Marker): void { if (!this._isClearing) { - this.markers.splice(this.markers.indexOf(marker), 1); + marker.removeMarker(); } } } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index ab928eb378..5ce909b61b 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -6,6 +6,7 @@ import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; +import { Marker } from 'common/buffer/Marker'; import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; @@ -24,14 +25,14 @@ const CELL_SIZE = 3; /** Column count within current visible BufferLine(row). * The left-most column is column 0. */ -type BufferColumn = number; +export type BufferColumn = number; /** Column count within current LogicalLine. * If the display is 80 columns wide, then LineColumn of the left-most * character of the first wrapped line would normally be 80. * (It might be 79 if the character at column 79 is double-width.) */ -type LogicalColumn = number; +export type LogicalColumn = number; /** * Cell member indices. @@ -68,7 +69,10 @@ export class LogicalLine { * @internal */ public _combined: {[index: LogicalColumn]: string} = {}; - + /** + * @internal + */ + public _firstMarker: Marker | undefined; /** * @internal */ @@ -856,10 +860,18 @@ export class BufferLine implements IBufferLine { } } */ - + let prevLastMarker; + for (let m = logicalLine._firstMarker; m; m = m._nextMarker) { + prevLastMarker = m; + } + let m = oldLogical._firstMarker; + if (prevLastMarker) prevLastMarker._nextMarker = m; + else logicalLine._firstMarker = m; + for (; m; m = m._nextMarker) { + m._startColumn += column; + } + oldLogical._firstMarker = undefined; logicalLine.length = column + oldLogical.length; - if ((globalThis as any).xyz) console.log('- llen='+column+'+'+oldLogical.length); - logicalLine.backgroundColor = oldLogical.backgroundColor; previousLine.nextBufferLine = this; for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { line.startColumn += column; @@ -883,6 +895,22 @@ export class BufferLine implements IBufferLine { nextRow.startColumn -= oldStartColumn; nextRow.logicalLine = newLogical; } + let prevMarker: Marker | undefined; // in oldLine marker list + let newMarkerLast: Marker | undefined; // in newLogical marker list + for (let m = oldLine._firstMarker; m; ) { + const oldNext = m._nextMarker; + if (m._startColumn >= oldStartColumn) { // move to new line + m._startColumn -= oldStartColumn; + if (prevMarker) { prevMarker._nextMarker = oldNext; } + else { oldLine._firstMarker = oldNext; } + m._nextMarker = undefined; + if (newMarkerLast) { newMarkerLast._nextMarker = m; } + else { newLogical._firstMarker = m; } + newMarkerLast = m; + } + prevMarker = m; + m = oldNext; + } oldLine.length = oldStartColumn; oldLine.trimLength(); // FIXME truncate/resize diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index 59ad049b0d..0869baaca2 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -5,23 +5,83 @@ import { IDisposable, IMarker } from 'common/Types'; import { Emitter } from 'common/Event'; +import { Buffer } from 'common/buffer/Buffer'; +import { BufferLine, LogicalLine, LogicalColumn } from 'common/buffer/BufferLine'; import { dispose } from 'common/Lifecycle'; export class Marker implements IMarker { + public payload?: IDisposable; + private _buffer: Buffer | undefined; + private _lineData: LogicalLine | undefined; + /** + * @internal + */ + public _startColumn: LogicalColumn = -1; + /** + * @internal + */ + public _nextMarker: Marker | undefined; private static _nextId = 1; public isDisposed: boolean = false; private readonly _disposables: IDisposable[] = []; - private readonly _id: number = Marker._nextId++; + /** + * @deprecated + */ public get id(): number { return this._id; } + public addDisposable(o: T): T { + if (this.isDisposed) { + o.dispose(); + } else { + this._disposables.push(o); + } + return o; + } + private readonly _onDispose = this.register(new Emitter()); public readonly onDispose = this._onDispose.event; - constructor( - public line: number - ) { + public addToLine(buffer: Buffer, line: LogicalLine, startColumn: LogicalColumn): void { + this._buffer = buffer; + this._lineData = line; + this._startColumn = startColumn; + this._nextMarker = line._firstMarker; + line._firstMarker = this; + } + + /** + * Get corresponding line number. + * This uses an expensive linear search through the buffer, so should be avoided. + * @deprecated + * + */ + public get line(): number { + const buffer = this._buffer; + if (! buffer) { + return -1; + } + const nlines = buffer.lines.length; + let prevLine: LogicalLine | undefined; + for (let i: number = 0; i < nlines; i++) { + const lline = (buffer.lines.get(i) as BufferLine).logicalLine; + if (lline !== prevLine) { + for (let m = lline._firstMarker; m; m = m._nextMarker) { + if (m === this) { + let bline = lline.firstBufferLine; + for (let j = 0; ; j++) { + if (! bline || this._startColumn >= bline.startColumn) { + return i + j; + } + bline = bline?.nextBufferLine; + } + } + } + prevLine = lline; + } + } + return -1; } public dispose(): void { @@ -29,11 +89,32 @@ export class Marker implements IMarker { return; } this.isDisposed = true; - this.line = -1; // Emit before super.dispose such that dispose listeners get a change to react this._onDispose.fire(); dispose(this._disposables); this._disposables.length = 0; + this._buffer = undefined; + this._lineData = undefined; + this._startColumn = -1; + } + + public removeMarker(): void { + const lline = this._lineData; + if (! lline) { + return; + } + let prev: Marker | undefined; + for (let m = lline._firstMarker; m; ) { + const next = m._nextMarker; + if (m === this) { + if (prev) { prev._nextMarker = next; } + else { lline._firstMarker = next; } + break; + } + prev = m; + m = next; + } + this._nextMarker = undefined; } public register(disposable: T): T { diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985e..644da4061b 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -8,7 +8,8 @@ import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; -import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; +import { BufferLine } from 'common/buffer/BufferLine'; +import { IBufferLine, IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; // Work variables to avoid garbage collection @@ -89,7 +90,28 @@ export class DecorationService extends Disposable implements IDecorationService } } } + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, bline: BufferLine): void { + const lline = bline.logicalLine; + // FIXME needs some work to handle wrapped lines + /* + let wrapOffset = 0; + for (let line = lline.firstBufferLine; line; line = line.nextBufferLine) { + if (line === bline) { break; } + wrapOffset++; + } + */ + for (let marker = lline._firstMarker; marker; marker = marker._nextMarker) { + const d = marker.payload; + if (d instanceof Decoration) { + const xmin = d.options.x ?? 0; + const xmax = xmin + (d.options.width ?? 1); + if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { + callback(d); + } + } + } + } 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; @@ -144,6 +166,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + this.marker.payload = this; 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..c845edd7a1 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -3,9 +3,10 @@ * @license MIT */ -import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm'; +import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions, IBufferLine } from '@xterm/xterm'; import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; +import { BufferLine } from 'common/buffer/BufferLine'; import { createDecorator } from 'common/services/ServiceRegistry'; import type { Emitter, IEvent } from 'common/Event'; @@ -390,6 +391,7 @@ export interface IDecorationService extends IDisposable { * instead of an iterator as it's typically used in hot code paths. */ forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void; + forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: BufferLine): void; } export interface IInternalDecoration extends IDecoration { readonly options: IDecorationOptions; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index c8e08f509f..e687f8c5d2 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -561,14 +561,18 @@ declare module '@xterm/xterm' { export interface IMarker extends IDisposableWithEvent { /** * A unique identifier for this marker. + * @deprecated */ readonly id: number; /** * The actual line index in the buffer at this point in time. This is set to * -1 if the marker has been disposed. + * This is an expensive operation. + * @deprecated */ readonly line: number; + payload?: IDisposable; } /** From dbc9c2b0aec05fb25bee30ff95ed00f1421ddbc3 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 1 May 2026 21:24:49 -0700 Subject: [PATCH 05/20] Fix some testsuite regressions. Main thing is changing soe the CircularList's onTrim emitter gets called while lines are in the list, which means clearMarkers can be used. This complicates the slice function a bit. --- src/common/CircularList.ts | 67 ++++++++++++------- src/common/buffer/Buffer.test.ts | 1 + src/common/buffer/Buffer.ts | 28 +++----- src/common/buffer/BufferLine.ts | 14 ++++ src/common/buffer/Marker.ts | 1 + src/common/services/DecorationService.test.ts | 10 +-- src/common/services/DecorationService.ts | 11 ++- 7 files changed, 80 insertions(+), 52 deletions(-) diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index ffa3719f7c..454595d4b5 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -106,10 +106,13 @@ export class CircularList extends Disposable implements ICircularList { * @param value The value to push onto the list. */ public push(value: T): void { + const trimNeeded = this._length === this._maxLength; + if (trimNeeded) { + this.onTrimEmitter.fire(1); + } this._array[this._getCyclicIndex(this._length)] = value; - if (this._length === this._maxLength) { + if (trimNeeded) { this._startIndex = ++this._startIndex % this._maxLength; - this.onTrimEmitter.fire(1); } else { this._length++; } @@ -140,34 +143,50 @@ export class CircularList extends Disposable implements ICircularList { * @param items The items to insert. */ public splice(start: number, deleteCount: number, ...items: T[]): void { + let trimTodo = Math.max(0, (this._length + items.length - deleteCount) - this._maxLength); + if (trimTodo > 0) { + const preTrim = Math.min(start, trimTodo); + this.trimStart(preTrim); + trimTodo -= preTrim; + start -= preTrim; + } // Delete items if (deleteCount) { + this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); for (let i = start; i < this._length - deleteCount; i++) { this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; } this._length -= deleteCount; - this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); - } - - // Add items - for (let i = this._length - 1; i >= start; i--) { - this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)]; } - for (let i = 0; i < items.length; i++) { - this._array[this._getCyclicIndex(start + i)] = items[i]; + const postTrim = trimTodo - items.length; + if (postTrim > 0) { + this.trimStart(postTrim); + trimTodo -= postTrim; } - if (items.length) { - this.onInsertEmitter.fire({ index: start, amount: items.length }); - } - - // Adjust length as needed - if (this._length + items.length > this._maxLength) { - const countToTrim = (this._length + items.length) - this._maxLength; - this._startIndex += countToTrim; - this._length = this._maxLength; - this.onTrimEmitter.fire(countToTrim); - } else { - this._length += items.length; + let firstItem = 0; + let itemsTodo = items.length; + while (itemsTodo > 0) { + const availSpace = this._maxLength - this.length; + const itemsAvail = Math.min(availSpace, itemsTodo); + // Add items + for (let i = this._length - 1; i >= start; i--) { + this._array[this._getCyclicIndex(i + itemsAvail)] = this._array[this._getCyclicIndex(i)]; + } + for (let i = 0; i < itemsAvail; i++) { + this._array[this._getCyclicIndex(start + i)] = items[firstItem + i]; + } + this._length += itemsAvail; + if (items.length) { + this.onInsertEmitter.fire({ index: start, amount: itemsAvail }); + } + if (trimTodo > 0) { + const trimAvail = Math.min(trimTodo, itemsAvail); + this.trimStart(trimAvail); + trimTodo -= trimAvail; + } + itemsTodo -= itemsAvail; + firstItem += itemsAvail; + start += itemsAvail; } } @@ -179,9 +198,9 @@ export class CircularList extends Disposable implements ICircularList { if (count > this._length) { count = this._length; } + this.onTrimEmitter.fire(count); this._startIndex += count; this._length -= count; - this.onTrimEmitter.fire(count); } public shiftElements(start: number, count: number, offset: number): void { @@ -203,9 +222,9 @@ export class CircularList extends Disposable implements ICircularList { if (expandListBy > 0) { this._length += expandListBy; while (this._length > this._maxLength) { + this.onTrimEmitter.fire(1); this._length--; this._startIndex++; - this.onTrimEmitter.fire(1); } } } else { diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 4fded177d8..44fcdea62d 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -494,6 +494,7 @@ describe('Buffer', () => { assert.equal(firstMarker.line, 0); assert.equal(secondMarker.line, 1); assert.equal(thirdMarker.line, 2); + buffer.resize(2, 11); assert.equal(buffer.lines.get(0)!.translateToString(), 'ij'); assert.equal(buffer.lines.get(1)!.translateToString(), '01'); assert.equal(buffer.lines.get(2)!.translateToString(), '23'); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 690eccff4e..d16f4a3b39 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -80,11 +80,19 @@ export class Buffer implements IBuffer { this.setupTabStops(); this.lines.onTrim(amount => { + for (let i = 0; i < amount; i++) { + this.clearMarkers(i); + } const first = this.lines.length && this.lines.get(0); if (first instanceof BufferLine && first.isWrapped) { const prev = first.getPreviousLine(); prev && first.asUnwrapped(prev); }}); + this.lines.onDelete(event => { + for (let i = event.amount; --i >= 0; ) { + this.clearMarkers(event.index + i); + } + }); } public getNullCell(attr?: IAttributeData): ICellData { @@ -420,7 +428,6 @@ export class Buffer implements IBuffer { const logical = curLine.logicalLine; for (;;) { const endCol = logical.charStart(startCol + newCols); - if ((this as any).xyz) console.log('-curR:'+curRow+' endCol:'+endCol); if (endCol >= logical.length) { curLine.nextBufferLine = undefined; curLine.startColumn = startCol; @@ -561,11 +568,9 @@ export class Buffer implements IBuffer { const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); if (amountToTrim > 0) { - /* for (let i = 0; i < amountToTrim; i++) { this.clearMarkers(i); } - */ } } } @@ -647,15 +652,7 @@ export class Buffer implements IBuffer { */ public clearMarkers(y: number): void { this._isClearing = true; - const bline = this.lines.get(y) as BufferLine; - const startColumn = bline.startColumn; - const endColumn = bline.nextBufferLine ? bline.nextBufferLine.startColumn : Infinity; - const lline = bline.logicalLine; - for (let m = lline._firstMarker; m; m = m._nextMarker) { - if (m._startColumn >= startColumn && m._startColumn < endColumn) { - m.dispose(); - } - } + (this.lines.get(y) as BufferLine).clearMarkers(); this._isClearing = false; } @@ -678,13 +675,6 @@ export class Buffer implements IBuffer { const lline = bline.logicalLine; const m = marker ?? new Marker(); m.addToLine(this, lline, x ?? bline.startColumn); - m.register(m.onDispose(() => this._removeMarker(m))); return m; } - - private _removeMarker(marker: Marker): void { - if (!this._isClearing) { - marker.removeMarker(); - } - } } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 5ce909b61b..e879588256 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -645,6 +645,20 @@ export class BufferLine implements IBufferLine { } } + /** + * @internal + */ + public clearMarkers(): void { + const lline = this.logicalLine; + const startColumn = this.startColumn; + const endColumn = this.nextBufferLine ? this.nextBufferLine.startColumn : Infinity; + for (let m = lline._firstMarker; m; m = m._nextMarker) { + if (m._startColumn >= startColumn && m._startColumn < endColumn) { + m.dispose(); + } + } + } + /** * Resize to `cols` filling excess cells with `fillCellData`. * The underlying array buffer will not change if there is still enough space diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index 0869baaca2..c0fada07eb 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -93,6 +93,7 @@ export class Marker implements IMarker { this._onDispose.fire(); dispose(this._disposables); this._disposables.length = 0; + this.removeMarker(); this._buffer = undefined; this._lineData = undefined; this._startColumn = -1; diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e244..3be3a7743d 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,15 +8,11 @@ import { 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 } from 'common/TestUtils.test'; function createFakeMarker(line: number): IMarker { - return Object.freeze(new class extends Disposable { - public readonly id = 1; - public readonly line = line; - public readonly isDisposed = false; - public readonly onDispose = new Emitter().event; - }()); + const bufferService = new MockBufferService(80, 30); + return bufferService.buffer.addMarker(line); } const fakeMarker: IMarker = createFakeMarker(1); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 644da4061b..bba7f6270d 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -9,7 +9,8 @@ import { IDecorationService, IInternalDecoration, ILogService } from 'common/ser import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; import { BufferLine } from 'common/buffer/BufferLine'; -import { IBufferLine, IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; +import { Marker } from 'common/buffer/Marker'; +import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; // Work variables to avoid garbage collection @@ -72,6 +73,10 @@ export class DecorationService extends Disposable implements IDecorationService this._decorations.clear(); } + /** + * Only used in tests. + * @param @deprecated + */ public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { let xmin = 0; let xmax = 0; @@ -100,10 +105,11 @@ export class DecorationService extends Disposable implements IDecorationService wrapOffset++; } */ + x += bline.startColumn; for (let marker = lline._firstMarker; marker; marker = marker._nextMarker) { const d = marker.payload; if (d instanceof Decoration) { - const xmin = d.options.x ?? 0; + const xmin = marker._startColumn; const xmax = xmin + (d.options.width ?? 1); if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { callback(d); @@ -166,6 +172,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + if (options.x) { (this.marker as Marker)._startColumn += options.x; } this.marker.payload = this; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; From 894cf712c7234093e9ee31cda4a5ee9ccd39f3a5 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 2 May 2026 16:33:43 -0700 Subject: [PATCH 06/20] Fix test so it works with new marker implementation. --- src/common/buffer/Buffer.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 44fcdea62d..0d520d4387 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1071,9 +1071,9 @@ describe('Buffer', () => { buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); - assert.equal(marker.line, buffer.lines.length - 1); - buffer.lines.onTrimEmitter.fire(1); - assert.equal(marker.line, buffer.lines.length - 2); + assert.equal(marker.line, 23); + buffer.lines.trimStart(1); + assert.equal(marker.line, 22); }); it('should dispose of a marker if it is trimmed off the buffer', () => { buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); From 4f3a8634d8502e2a204be5c0ad74fb22cf6e0441 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 4 May 2026 17:29:09 -0700 Subject: [PATCH 07/20] Cleanup after merge. Added LogicalLine.forEachMarker. --- src/browser/TestUtils.test.ts | 4 ++-- .../renderer/dom/DomRendererRowFactory.ts | 5 ++--- src/common/TestUtils.test.ts | 3 +-- src/common/Types.ts | 3 +++ src/common/buffer/BufferLine.ts | 8 ++++++- src/common/services/DecorationService.ts | 21 +++++++------------ src/common/services/Services.ts | 7 +++---- 7 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 0ac6064cd8..d335f43c22 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -8,7 +8,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseS import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; import { IColorSet, ITerminal, ILinkifier2, IBrowser, IViewport, ICompositionHelper, CharacterJoinerHandler, IBufferRange, ReadonlyColorSet, IBufferElementProvider } from 'browser/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset, ITerminalOptions, ColorIndex } from 'common/Types'; +import { IBufferLine, ICellData, IAttributeData, ICircularList, ILogicalLine, XtermListener, ICharset, ITerminalOptions, ColorIndex } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import * as Browser from 'common/Platform'; import { CoreBrowserTerminal } from 'browser/CoreBrowserTerminal'; @@ -255,7 +255,7 @@ export class MockBuffer implements IBuffer { public setLines(lines: ICircularList): void { this.lines = lines; } - public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { + public getBlankLine(attr: IAttributeData, logicalLine?: ILogicalLine): IBufferLine { return Buffer.prototype.getBlankLine.apply(this, arguments as any); } public getNullCell(attr?: IAttributeData): ICellData { diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 540a9bf91c..49204a3c1f 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -13,7 +13,6 @@ import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'bro import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; import { AttributeData } from 'common/buffer/AttributeData'; -import { BufferLine } from 'common/buffer/BufferLine'; import { WidthCache } from 'browser/renderer/dom/WidthCache'; import { IColorContrastCache } from 'browser/Types'; @@ -171,7 +170,7 @@ export class DomRendererRowFactory { let isDecorated = false; this._decorationService.forEachDecorationAtCellLine(x, row, undefined, d => { isDecorated = true; - }, lineData as BufferLine); + }, lineData); // get chars to render for this cell let chars = cell.getChars() || WHITESPACE_CELL_CHAR; @@ -374,7 +373,7 @@ export class DomRendererRowFactory { fgOverride = d.foregroundColorRGB; } isTop = d.options.layer === 'top'; - }, lineData as BufferLine); + }, lineData); // Apply selection if (!isTop && isInSelection) { diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index bf0d643669..63cce550d0 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -14,7 +14,6 @@ import { UnicodeV6 } from 'common/input/UnicodeV6'; import { IDecorationOptions, IDecoration } from '@xterm/xterm'; import { Emitter, type IEvent } from 'common/Event'; import { CellData } from 'common/buffer/CellData'; -import { BufferLine } from 'common/buffer/BufferLine'; import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from 'common/buffer/Constants'; export function createCellData(attr: number, char: string, width: number): CellData { @@ -240,6 +239,6 @@ export class MockDecorationService implements IDecorationService { public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { return undefined; } public reset(): void { } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { } - public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: BufferLine): void { } + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: IBufferLine): void { } public dispose(): void { } } diff --git a/src/common/Types.ts b/src/common/Types.ts index f82483bd6d..3ef77dfc4b 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -219,12 +219,15 @@ export interface ICellData extends IAttributeData { } export interface ILogicalLine { + forEachMarker(callback: (marker: IMarker) => void): void; } /** * Interface for a line in the terminal buffer. */ export interface IBufferLine { + logicalLine: ILogicalLine; + startColumn: number; length: number; get isWrapped(): boolean; get(index: number): CharData; diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index a3b41f237e..5a10d001b0 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs } from 'common/Types'; +import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs, IMarker } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; import { Marker } from 'common/buffer/Marker'; @@ -99,6 +99,12 @@ export class LogicalLine implements ILogicalLine { this._data = data; } + public forEachMarker(callback: (marker: IMarker) => void): void { + for (let m= this._firstMarker; m; m = m._nextMarker) { + callback(m); + } + } + /** * @internal */ diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index bba7f6270d..1687c295cc 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -7,7 +7,7 @@ import { css } from 'common/Color'; import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, IBufferLine } from 'common/Types'; import { BufferLine } from 'common/buffer/BufferLine'; import { Marker } from 'common/buffer/Marker'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; @@ -95,29 +95,22 @@ export class DecorationService extends Disposable implements IDecorationService } } } - public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, bline: BufferLine): void { + + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, bline: IBufferLine): void { const lline = bline.logicalLine; - // FIXME needs some work to handle wrapped lines - /* - let wrapOffset = 0; - for (let line = lline.firstBufferLine; line; line = line.nextBufferLine) { - if (line === bline) { break; } - wrapOffset++; - } - */ x += bline.startColumn; - for (let marker = lline._firstMarker; marker; marker = marker._nextMarker) { + lline.forEachMarker((marker: IMarker) => { const d = marker.payload; if (d instanceof Decoration) { - const xmin = marker._startColumn; + const xmin = (marker as Marker)._startColumn; const xmax = xmin + (d.options.width ?? 1); if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { callback(d); } } - } - + }); } + 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; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index c845edd7a1..51cfc8840b 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -3,10 +3,9 @@ * @license MIT */ -import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions, IBufferLine } from '@xterm/xterm'; -import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; +import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm'; +import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, IBufferLine, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { BufferLine } from 'common/buffer/BufferLine'; import { createDecorator } from 'common/services/ServiceRegistry'; import type { Emitter, IEvent } from 'common/Event'; @@ -391,7 +390,7 @@ export interface IDecorationService extends IDisposable { * instead of an iterator as it's typically used in hot code paths. */ forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void; - forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: BufferLine): void; + forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: IBufferLine): void; } export interface IInternalDecoration extends IDecoration { readonly options: IDecorationOptions; From 345bf14e8f4211086730978772c1e3a5e3c8f155 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 17 May 2026 13:13:19 -0700 Subject: [PATCH 08/20] Faster and siompler buffer reflow. Other cleanups and improvements. --- src/common/CircularList.ts | 23 +- src/common/Types.ts | 2 + src/common/buffer/Buffer.test.ts | 1 + src/common/buffer/Buffer.ts | 487 +++++++++-------------- src/common/buffer/BufferLine.ts | 4 + src/common/buffer/BufferReflow.ts | 56 --- src/common/buffer/Marker.ts | 1 + src/common/services/DecorationService.ts | 3 +- 8 files changed, 208 insertions(+), 369 deletions(-) diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 454595d4b5..bdefe5d723 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -143,16 +143,22 @@ export class CircularList extends Disposable implements ICircularList { * @param items The items to insert. */ public splice(start: number, deleteCount: number, ...items: T[]): void { + this.spliceItems(start, deleteCount, items, false); + } + + public spliceItems(start: number, deleteCount: number, items: T[], onlyTrimEvents: boolean): void { let trimTodo = Math.max(0, (this._length + items.length - deleteCount) - this._maxLength); - if (trimTodo > 0) { - const preTrim = Math.min(start, trimTodo); + const preTrim = Math.min(start, trimTodo); + if (preTrim > 0) { this.trimStart(preTrim); trimTodo -= preTrim; start -= preTrim; } // Delete items if (deleteCount) { - this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); + if (! onlyTrimEvents) { + this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); + } for (let i = start; i < this._length - deleteCount; i++) { this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; } @@ -163,10 +169,14 @@ export class CircularList extends Disposable implements ICircularList { this.trimStart(postTrim); trimTodo -= postTrim; } - let firstItem = 0; - let itemsTodo = items.length; + // If trimTodo > 0 we will have to trim some of the inserted items. + // Copy in chunks (as many items as will not exceed _maxLength) + // in order to fire trim and insert events properly. + let firstItem = 0; // next index in items array + let itemsTodo = items.length; // number of items yet to insert while (itemsTodo > 0) { const availSpace = this._maxLength - this.length; + // Number of items we can insert this iteration. const itemsAvail = Math.min(availSpace, itemsTodo); // Add items for (let i = this._length - 1; i >= start; i--) { @@ -176,13 +186,14 @@ export class CircularList extends Disposable implements ICircularList { this._array[this._getCyclicIndex(start + i)] = items[firstItem + i]; } this._length += itemsAvail; - if (items.length) { + if (items.length && ! onlyTrimEvents) { this.onInsertEmitter.fire({ index: start, amount: itemsAvail }); } if (trimTodo > 0) { const trimAvail = Math.min(trimTodo, itemsAvail); this.trimStart(trimAvail); trimTodo -= trimAvail; + start -= trimAvail; } itemsTodo -= itemsAvail; firstItem += itemsAvail; diff --git a/src/common/Types.ts b/src/common/Types.ts index 3ef77dfc4b..7c7bbf4f5e 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -220,6 +220,8 @@ export interface ICellData extends IAttributeData { export interface ILogicalLine { forEachMarker(callback: (marker: IMarker) => void): void; + reflowNeeded: boolean; + isEmpty(): boolean; } /** diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 72cc7191e1..3b40f50ffb 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1003,6 +1003,7 @@ describe('Buffer', () => { for (let i = 0; i < 10; i++) { buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); } + buffer.y = 9; buffer.ybase = 10; }); describe('&& ydisp === ybase', () => { diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index f0f4684439..111e9479de 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -3,13 +3,12 @@ * @license MIT */ -import { CircularList, IInsertEvent } from 'common/CircularList'; +import { CircularList } from 'common/CircularList'; import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; import { BufferLine, LogicalLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; -import { reflowLargerApplyNewLayout, reflowLargerCreateNewLayout } from 'common/buffer/BufferReflow'; import { CellData } from 'common/buffer/CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants'; import { Marker } from 'common/buffer/Marker'; @@ -44,6 +43,11 @@ export class Buffer extends Disposable implements IBuffer { public savedGlevel: number = 0; public savedOriginMode: boolean = false; public savedWraparoundMode: boolean = true; + /** Reflow may be needed for line indexes less than lastReflowNeeded. + * I.e. if i >= lastReflowNeeded then lines.get(i).reflowNeeded is false. + * Lines later in the buffer are more likly to be visible and hence + * have been updated. */ + public lastReflowNeeded: number = 0; /** * This is an expensive operation. * @deprecated @@ -87,7 +91,7 @@ export class Buffer extends Disposable implements IBuffer { for (let i = 0; i < amount; i++) { this.clearMarkers(i); } - const first = this.lines.length && this.lines.get(0); + const first = this.lines.length && this.lines.get(amount); if (first instanceof BufferLine && first.isWrapped) { const prev = first.getPreviousLine(); prev && first.asUnwrapped(prev); @@ -217,63 +221,33 @@ export class Buffer extends Disposable implements IBuffer { if (newMaxLength > this.lines.maxLength) { this.lines.maxLength = newMaxLength; } - - // if (this._cols > newCols) { - // console.log('increase!'); - // } - - // The following adjustments should only happen if the buffer has been - // initialized/filled. - if (this.lines.length > 0) { - // Deal with columns increasing (reducing needs to happen after reflow) - if (this._cols < newCols) { - for (let i = 0; i < this.lines.length; i++) { - this.lines.get(i)!.length = newCols; - } + const ybaseOld = this.ybase; + const ydispOld = this.ydisp; + if (newRows < this._rows || newCols < this._cols) { + const minHeight = Math.max(this.savedY, this.ybase + this.y) + 1; + while (this.lines.length > minHeight && this.lines.get(this.lines.length - 1)?.logicalLine.isEmpty()) { + this.lines.pop(); } + } - // Resize rows in both directions as needed - let addToY = 0; - if (this._rows < newRows) { - for (let y = this._rows; y < newRows; y++) { - if (this.lines.length < newRows + this.ybase) { - if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { - // Just add the new missing rows on Windows as conpty reprints the screen with its - // view of the world. Once a line enters scrollback for conpty it remains there - this.lines.push(new BufferLine(this._stringCache, newCols)); - } else { - if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { - // There is room above the buffer and there are no empty elements below the line, - // scroll up - this.ybase--; - addToY++; - if (this.ydisp > 0) { - // Viewport is at the top of the buffer, must increase downwards - this.ydisp--; - } - } else { - // Add a blank line if there is no buffer left at the top to scroll to, or if there - // are blank lines after the cursor - this.lines.push(new BufferLine(this._stringCache, newCols)); - } - } - } - } - } else { // (this._rows >= newRows) - for (let y = this._rows; y > newRows; y--) { - if (this.lines.length > newRows + this.ybase) { - if (this.lines.length > this.ybase + this.y + 1) { - // The line is a blank line below the cursor, remove it - this.lines.pop(); - } else { - // The line is the cursor, scroll down - this.ybase++; - this.ydisp++; - } + if (this._cols !== newCols) { + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + const line = this.lines.get(i) as BufferLine; + line.length = newCols; + const logical = line.logicalLine; + if (! line.isWrapped) { + if (line.nextBufferLine || logical.length > newCols) { + logical.reflowNeeded = true; + this.lastReflowNeeded = Math.max(i, this.lastReflowNeeded); } } } + } + // The following adjustments should only happen if the buffer has been + // initialized/filled. + if (this.lines.length > 0) { // Reduce max length if needed after adjustments, this is done after as it // would otherwise cut data from the bottom of the buffer. if (newMaxLength < this.lines.maxLength) { @@ -291,9 +265,6 @@ export class Buffer extends Disposable implements IBuffer { // Make sure that the cursor stays on screen this.x = Math.min(this.x, newCols - 1); this.y = Math.min(this.y, newRows - 1); - if (addToY) { - this.y += addToY; - } this.savedX = Math.min(this.savedX, newCols - 1); this.scrollTop = 0; @@ -301,26 +272,48 @@ export class Buffer extends Disposable implements IBuffer { this.scrollBottom = newRows - 1; - if (this._isReflowEnabled) { - this._reflow(newCols, newRows); - - // Trim the end of the line off if cols shrunk - if (this._cols > newCols) { - for (let i = 0; i < this.lines.length; i++) { - this.lines.get(i)!.length = newCols; - } - } - } - + const lazyReflow = false; // FUTURE - change to true? + const reflowNow = this._isReflowEnabled && this._cols !== newCols && ! lazyReflow; + this._cols = newCols; + this._rows = newRows; + this.reflowRegion(reflowNow ? 0 : this.ydisp, this.lines.length, + reflowNow? -1 : newRows); this._cols = newCols; this._rows = newRows; + let ypos = Math.max(this.savedY, this.ybase + this.y) + 1; + if (this._optionsService.rawOptions.windowsPty.backend !== undefined + || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { + // Just add the new missing rows on Windows as conpty reprints + // the screen with its view of the world. + // Once a line enters scrollback for conpty it remains there + ypos = Math.max(newRows + this.ybase, ypos); + } else { + ypos = Math.max(newRows, ypos); + } + while (ypos > this.lines.length + && this.lines.length < this.lines.maxLength) { + // Add an extra row at the bottom of the viewport + this.lines.push(new BufferLine(this._stringCache, newCols)); + } // Ensure the cursor position invariant: ybase + y must be within buffer bounds // This can be violated during reflow or when shrinking rows - if (this.lines.length > 0) { - const maxY = Math.max(0, this.lines.length - this.ybase - 1); - this.y = Math.min(this.y, maxY); + if (this.y < 0) { this.y = 0; } // sanity check shouldn't happen + if (this.ybase >= this.lines.length) { + this.ybase = this.lines.length - 1; this.y = 0; + } + const newHome = Math.max(0, this.lines.length - newRows); + this.y += this.ybase - newHome; + this.ybase = newHome; + if (this.savedY < 0 || this.savedY >= this.lines.length) { + this.savedY = 0; } + + // Not sure this is the correct approach: It seems we should adjust ydisp + // depending on reflow/trimming of previous lines, like with do for ybase. + // (The logic in _reflowRegion does handle the necessary updates.) + // However, the following is what the testsuite currently expects. --PB + this.ydisp = ybaseOld === ydispOld ? this.ybase : ydispOld; } private get _isReflowEnabled(): boolean { @@ -331,256 +324,140 @@ export class Buffer extends Disposable implements IBuffer { return this._hasScrollback; } - private _reflow(newCols: number, newRows: number): void { - if (this._cols === newCols) { + public reflowRegion(startRow: number, endRow: number, maxRows: number): void { + if (startRow > this.lastReflowNeeded) { return; } - - // Iterate through rows, ignore the last one as it cannot be wrapped - if (newCols > this._cols) { - this._reflowLarger(newCols, newRows); - } else { - this._reflowSmaller(newCols, newRows); - } - } - - /** - * Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed - * when a wrapped line unwraps. - * @param lines The buffer lines. - * @param oldCols The columns before resize - * @param newCols The columns after resize. - * @param bufferAbsoluteY The absolute y position of the cursor (baseY + cursorY). - * @param nullCell The cell data to use when filling in empty cells. - * @param reflowCursorLine Whether to reflow the line containing the cursor. - */ - private _reflowLargerGetLinesToRemove(lines: CircularList, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] { - // Gather all BufferLines that need to be removed from the Buffer here so that they can be - // batched up and only committed once - const toRemove: number[] = []; - - for (let y = 0; y < lines.length - 1; y++) { - // Check if this row is wrapped - let i = y; - let nextLine = lines.get(++i) as BufferLine; - if (!nextLine.isWrapped) { - continue; - } - - // Check how many lines it's wrapped for - const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine]; - while (i < lines.length && nextLine.isWrapped) { - wrappedLines.push(nextLine); - nextLine = lines.get(++i) as BufferLine; - } - - if (!reflowCursorLine) { - // If these lines contain the cursor don't touch them, the program will handle fixing up - // wrapped lines with the cursor - if (bufferAbsoluteY >= y && bufferAbsoluteY < i) { - y += wrappedLines.length - 1; - continue; - } - } - const oldWrapped = wrappedLines.length; - this._reflowLine(wrappedLines, newCols); - - // Work backwards and remove any rows at the end that only contain null cells - const countToRemove = oldWrapped - wrappedLines.length; - if (countToRemove > 0) { - toRemove.push(y + oldWrapped - countToRemove); // index - toRemove.push(countToRemove); - } - - y += oldWrapped - 1; + if (endRow >= this.lastReflowNeeded) { + this.lastReflowNeeded = startRow; } - return toRemove; - } - - private _reflowLarger(newCols: number, newRows: number): void { const reflowCursorLine = this._optionsService.rawOptions.reflowCursorLine; - const toRemove: number[] = this._reflowLargerGetLinesToRemove(this.lines, this._cols, newCols, this.ybase + this.y, this.getNullCell(DEFAULT_ATTR_DATA), reflowCursorLine); - if (toRemove.length > 0) { - const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); - reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); - this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved); + const newCols = this._cols; + while (startRow > 0 && this.lines.get(startRow)?.isWrapped) { + startRow--; + if (maxRows >= 0) { maxRows++; } } - } - - private _reflowLargerAdjustViewport(newCols: number, newRows: number, countRemoved: number): void { - // Adjust viewport based on number of items removed - let viewportAdjustments = countRemoved; - while (viewportAdjustments-- > 0) { - if (this.ybase === 0) { - if (this.y > 0) { - this.y--; - } - if (this.lines.length < newRows) { - // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(this._stringCache, newCols)); - } - } else { - if (this.ydisp === this.ybase) { - this.ydisp--; - } - this.ybase--; - } - } - this.savedY = Math.max(this.savedY - countRemoved, 0); - } - private _reflowLine(wrappedLines: BufferLine[], newCols: number): BufferLine[] { const newLines: BufferLine[] = []; - let startCol = 0; - let curRow = 1; - let curLine = wrappedLines[0]; - const logical = curLine.logicalLine; - for (;;) { - const endCol = logical.charStart(startCol + newCols); - if (endCol >= logical.length) { - curLine.nextBufferLine = undefined; - curLine.startColumn = startCol; - break; - } - let newLine; - if (curRow < wrappedLines.length) { - newLine = wrappedLines[curRow]; - newLine.length = newCols; - } else { - newLine = new BufferLine(this._stringCache, newCols, logical); - newLines.push(newLine); - } - curRow++; - newLine.startColumn = endCol; - startCol = endCol; - curLine.nextBufferLine = newLine; - curLine = newLine; - } - if (curRow < wrappedLines.length) { - wrappedLines.length = curRow; + const yDispOld = this.ydisp; + const yBaseOld = this.ybase; + const yAbsOld = yBaseOld + this.y; + let yAbs = yAbsOld; + const ySavedOld = this.savedY; + let ySaved = ySavedOld; + if (! reflowCursorLine && yAbs >= 0 && yAbs < this.lines.length) { + const cursorLine = this.lines.get(yAbsOld) as BufferLine; + cursorLine.logicalLine.reflowNeeded = false; } - return newLines; - } - - private _reflowSmaller(newCols: number, newRows: number): void { - const reflowCursorLine = this._optionsService.rawOptions.reflowCursorLine; - // Gather all BufferLines that need to be inserted into the Buffer here so that they can be - // batched up and only committed once - const toInsert = []; - let countToInsert = 0; - // Go backwards as many lines may be trimmed and this will avoid considering them - for (let y = this.lines.length - 1; y >= 0; y--) { - // Check whether this line is a problem - let nextLine = this.lines.get(y) as BufferLine; - if (!nextLine || !nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) { - continue; - } - // Gather wrapped lines and adjust y to be the starting line - const wrappedLines: BufferLine[] = [nextLine]; - while (nextLine.isWrapped && y > 0) { - nextLine = this.lines.get(--y) as BufferLine; - wrappedLines.unshift(nextLine); - } - - if (!reflowCursorLine) { - // If these lines contain the cursor don't touch them, the program will handle fixing up - // wrapped lines with the cursor - const absoluteY = this.ybase + this.y; - if (absoluteY >= y && absoluteY < y + wrappedLines.length) { - continue; - } - } - const newLines = this._reflowLine(wrappedLines, newCols); - const linesToAdd = newLines.length; - let trimmedLines: number; - if (this.ybase === 0 && this.y !== this.lines.length - 1) { - // If the top section of the buffer is not yet filled - trimmedLines = Math.max(0, this.y - this.lines.maxLength + linesToAdd); - } else { - trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd); - } - - if (newLines.length > 0) { - toInsert.push({ - // countToInsert here gets the actual index, taking into account other inserted items. - // using this we can iterate through the list forwards - start: y + wrappedLines.length + countToInsert, - newLines - }); - countToInsert += newLines.length; - wrappedLines.push(...newLines); + let deltaSoFar = 0; + for (let row = startRow; row < endRow;) { + if (maxRows >= 0 && newLines.length > maxRows) { + endRow = row; + break; } - // Adjust viewport as needed - let viewportAdjustments = linesToAdd - trimmedLines; - while (viewportAdjustments-- > 0) { - if (this.ybase === 0) { - if (this.y < newRows - 1) { - this.y++; - this.lines.pop(); - } else { - this.ybase++; - this.ydisp++; + const line = this.lines.get(row) as BufferLine; + newLines.push(line); + const logical = line.logicalLine; + if (line === logical.firstBufferLine && logical.reflowNeeded) { + let curLine: BufferLine = line; + + let logicalX; + let logicalSavedX = this.savedX; + let oldWrapCount = 0; // number of following wrapped lines + let nextLine = curLine; + for (; ; oldWrapCount++) { + if (yAbsOld === row + oldWrapCount) { + logicalX = nextLine.startColumn + this.x; + } + if (ySavedOld === row + oldWrapCount) { + logicalSavedX = nextLine.startColumn + this.savedX; } - } else { - // Ensure ybase does not exceed its maximum value - if (this.ybase < Math.min(this.lines.maxLength, this.lines.length + countToInsert) - newRows) { - if (this.ybase === this.ydisp) { - this.ydisp++; - } - this.ybase++; + if (! nextLine.nextBufferLine || row + oldWrapCount + 1 >= endRow) { + break; } + nextLine = nextLine.nextBufferLine; } - } - this.savedY = Math.min(this.savedY + linesToAdd, this.ybase + newRows - 1); - } - - // Rearrange lines in the buffer if there are any insertions, this is done at the end rather - // than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many - // costly calls to CircularList.splice. - if (toInsert.length > 0) { - // Record buffer insert events and then play them back backwards so that the indexes are - // correct - const insertEvents: IInsertEvent[] = []; - - // Record original lines so they don't get overridden when we rearrange the list - const originalLines: BufferLine[] = []; - for (let i = 0; i < this.lines.length; i++) { - originalLines.push(this.lines.get(i) as BufferLine); - } - const originalLinesLength = this.lines.length; - - let originalLineIndex = originalLinesLength - 1; - let nextToInsertIndex = 0; - let nextToInsert = toInsert[nextToInsertIndex]; - this.lines.length = Math.min(this.lines.maxLength, this.lines.length + countToInsert); - let countInsertedSoFar = 0; - for (let i = Math.min(this.lines.maxLength - 1, originalLinesLength + countToInsert - 1); i >= 0; i--) { - if (nextToInsert && nextToInsert.start > originalLineIndex + countInsertedSoFar) { - // Insert extra lines here, adjusting i as needed - for (let nextI = nextToInsert.newLines.length - 1; nextI >= 0; nextI--) { - this.lines.set(i--, nextToInsert.newLines[nextI]); + const lineRow = row; + row++; + const newWrapStart = newLines.length; + logical.reflowNeeded = false; + let startCol = 0; + for (;;) { + const endCol = logical.charStart(startCol + newCols); + if (endCol >= logical.length) { + curLine.nextBufferLine = undefined; + curLine.startColumn = startCol; + break; + } + const nextLine = row < endRow && this.lines.get(row); + let newLine; + if (nextLine && nextLine.isWrapped) { + newLine = nextLine as BufferLine; + newLine.length = newCols; + row++; + } else { + newLine = new BufferLine(this._stringCache, newCols, logical); } - i++; - - // Create insert events for later - insertEvents.push({ - index: originalLineIndex + 1, - amount: nextToInsert.newLines.length - }); - - countInsertedSoFar += nextToInsert.newLines.length; - nextToInsert = toInsert[++nextToInsertIndex]; - } else { - this.lines.set(i, originalLines[originalLineIndex--]); + newLines.push(newLine); + newLine.startColumn = endCol; + startCol = endCol; + curLine.nextBufferLine = newLine; + curLine = newLine; } - } - - const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); - if (amountToTrim > 0) { - for (let i = 0; i < amountToTrim; i++) { - this.clearMarkers(i); + while (row < endRow && this.lines.get(row)!.isWrapped) { + row++; + } + const newWrapCount = newLines.length - newWrapStart; + if (yBaseOld >= lineRow && yBaseOld <= lineRow + oldWrapCount) { + this.ybase = lineRow + deltaSoFar + + Math.min(yBaseOld - lineRow, newWrapCount); + } + if (yDispOld >= lineRow && yDispOld <= lineRow + oldWrapCount) { + this.ydisp = lineRow + deltaSoFar + + Math.min(yDispOld - lineRow, newWrapCount); + } + if (logicalX !== undefined) { // update cursor x and y + let i = newWrapStart; + while (i < newLines.length && newLines[i].startColumn <= logicalX) { i++; } + yAbs = startRow + i - 1 + deltaSoFar; + this.x = logicalX - newLines[i-1].startColumn; + } + if (logicalSavedX !== undefined) { // update cursor savedX and savedY + let i = newWrapStart; + while (i < newLines.length && newLines[i].startColumn <= logicalSavedX) { i++; } + ySaved = startRow + i - 1 + deltaSoFar; + this.savedX = logicalSavedX - newLines[i-1].startColumn; } + deltaSoFar += newWrapCount - oldWrapCount; + } else { + if (row === yBaseOld) { this.ybase = yBaseOld + deltaSoFar; } + if (row === yDispOld) { this.ydisp = yDispOld + deltaSoFar; } + if (row === yAbsOld) { + yAbs += deltaSoFar; + } + if (row === ySavedOld) { + ySaved += deltaSoFar; + } + row++; + } + } + if (deltaSoFar !== 0) { + if (yAbsOld >= endRow) { yAbs += deltaSoFar; } + if (ySavedOld >= endRow) { ySaved += deltaSoFar; } + if (deltaSoFar > 0) { + if (yBaseOld >= endRow) { this.ybase = yBaseOld + deltaSoFar; } + if (yDispOld >= endRow) { this.ydisp = yDispOld + deltaSoFar; } } } + const untrimmedLength = this.lines.length + deltaSoFar; + this.lines.spliceItems(startRow, endRow - startRow, newLines, true); + const trimmedLength = this.lines.length; + const trimmedCount = untrimmedLength - trimmedLength; + yAbs = Math.max(0, yAbs - trimmedCount); + ySaved = Math.max(0, ySaved - trimmedCount); + this.ybase = Math.max(0, this.ybase - trimmedCount); + this.ydisp = Math.max(0, this.ydisp - trimmedCount); + this.y = yAbs - this.ybase; + this.savedY = ySaved; } /** diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 5a10d001b0..2af876fe14 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -99,6 +99,10 @@ export class LogicalLine implements ILogicalLine { this._data = data; } + public isEmpty(): boolean { + return this.length === 0 && ! this._firstMarker; + } + public forEachMarker(callback: (marker: IMarker) => void): void { for (let m= this._firstMarker; m; m = m._nextMarker) { callback(m); diff --git a/src/common/buffer/BufferReflow.ts b/src/common/buffer/BufferReflow.ts index 9822ae261d..2c4fda3f94 100644 --- a/src/common/buffer/BufferReflow.ts +++ b/src/common/buffer/BufferReflow.ts @@ -4,68 +4,12 @@ */ import { BufferLine } from 'common/buffer/BufferLine'; -import { CircularList } from 'common/CircularList'; -import { IBufferLine } from 'common/Types'; export interface INewLayoutResult { layout: number[]; countRemoved: number; } -/** - * Creates and return the new layout for lines given an array of indexes to be removed. - * @param lines The buffer lines. - * @param toRemove The indexes to remove. - */ -export function reflowLargerCreateNewLayout(lines: CircularList, toRemove: number[]): INewLayoutResult { - const layout: number[] = []; - // First iterate through the list and get the actual indexes to use for rows - let nextToRemoveIndex = 0; - let nextToRemoveStart = toRemove[nextToRemoveIndex]; - let countRemovedSoFar = 0; - for (let i = 0; i < lines.length; i++) { - if (nextToRemoveStart === i) { - const countToRemove = toRemove[++nextToRemoveIndex]; - - // Tell markers that there was a deletion - lines.onDeleteEmitter.fire({ - index: i - countRemovedSoFar, - amount: countToRemove - }); - - i += countToRemove - 1; - countRemovedSoFar += countToRemove; - nextToRemoveStart = toRemove[++nextToRemoveIndex]; - } else { - layout.push(i); - } - } - return { - layout, - countRemoved: countRemovedSoFar - }; -} - -/** - * Applies a new layout to the buffer. This essentially does the same as many splice calls but it's - * done all at once in a single iteration through the list since splice is very expensive. - * @param lines The buffer lines. - * @param newLayout The new layout to apply. - */ -export function reflowLargerApplyNewLayout(lines: CircularList, newLayout: number[]): void { - // Record original lines so they don't get overridden when we rearrange the list - const newLayoutLines: BufferLine[] = []; - for (let i = 0; i < newLayout.length; i++) { - newLayoutLines.push(lines.get(newLayout[i]) as BufferLine); - } - - // Rearrange the list - for (let i = 0; i < newLayoutLines.length; i++) { - lines.set(i, newLayoutLines[i]); - } - lines.length = newLayout.length; -} - export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number { // If this is the last row in the wrapped line, get the actual trimmed length if (i === lines.length - 1) { diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index c0fada07eb..32e2d3c893 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -85,6 +85,7 @@ export class Marker implements IMarker { } public dispose(): void { + if ((globalThis as any).xyz && this.id===27) console.trace('dispose M:'+this.id); if (this.isDisposed) { return; } diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 1687c295cc..30acbfa02e 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor, IBufferLine } from 'common/Types'; -import { BufferLine } from 'common/buffer/BufferLine'; import { Marker } from 'common/buffer/Marker'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; @@ -75,7 +74,7 @@ export class DecorationService extends Disposable implements IDecorationService /** * Only used in tests. - * @param @deprecated + * @deprecated */ public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { let xmin = 0; From 2a9096c75373245596ba9c7c83754ff02a6feb32 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Wed, 13 May 2026 11:29:00 -0700 Subject: [PATCH 09/20] Change image storage to use new ExtendedAttrs payload field. --- addons/addon-image/src/ImageStorage.ts | 204 ++++++++----------------- addons/addon-image/src/Types.ts | 19 +-- src/common/Types.ts | 1 + src/common/buffer/AttributeData.ts | 3 +- 4 files changed, 73 insertions(+), 154 deletions(-) diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 8c89a16b08..d7aa59dbf4 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -5,7 +5,10 @@ import { IDisposable } from '@xterm/xterm'; import { ImageRenderer } from './ImageRenderer'; -import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types'; +import { ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, ImageLayer } from './Types'; +import { IBufferLine } from 'common/Types'; +import { CellData } from 'common/buffer/CellData'; + // fallback default cell size @@ -14,93 +17,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = { height: 14 }; -/** - * Extend extended attribute to also hold image tile information. - * - * Object definition is copied from base repo to fully mimick its behavior. - * Image data is added as additional public properties `imageId` and `tileId`. - */ -class ExtendedAttrsImage implements IExtendedAttrsImage { - private _ext: number = 0; - public get ext(): number { - if (this._urlId) { - return ( - (this._ext & ~ExtFlags.UNDERLINE_STYLE) | - (this.underlineStyle << 26) - ); - } - return this._ext; - } - public set ext(value: number) { this._ext = value; } - - public get underlineStyle(): UnderlineStyle { - // Always return the URL style if it has one - if (this._urlId) { - return UnderlineStyle.DASHED; - } - return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26; - } - public set underlineStyle(value: UnderlineStyle) { - this._ext &= ~ExtFlags.UNDERLINE_STYLE; - this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE; - } - - public get underlineColor(): number { - return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); - } - public set underlineColor(value: number) { - this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); - this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); - } - - public get underlineVariantOffset(): number { - const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29; - if (val < 0) { - return val ^ 0xFFFFFFF8; - } - return val; - } - public set underlineVariantOffset(value: number) { - this._ext &= ~ExtFlags.VARIANT_OFFSET; - this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET; - } - - private _urlId: number = 0; - public get urlId(): number { - return this._urlId; - } - public set urlId(value: number) { - this._urlId = value; - } - +class ImageTileInfo { constructor( - ext: number = 0, - urlId: number = 0, public imageId = -1, - public tileId = -1 - ) { - this._ext = ext; - this._urlId = urlId; - } - - public clone(): IExtendedAttrsImage { - /** - * Technically we dont need a clone variant of ExtendedAttrsImage, - * as we never clone a cell holding image data. - * Note: Clone is only meant to be used by the InputHandler for - * sticky attributes, which is never the case for image data. - * We still provide a proper clone method to reflect the full ext attr - * state in case there are future use cases for clone. - */ - return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId); - } - - public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1; + public tileId = -1) { } } -const EMPTY_ATTRS = new ExtendedAttrsImage(); - /** * ImageStorage - extension of CoreTerminal: @@ -122,6 +44,7 @@ export class ImageStorage implements IDisposable { private _needsFullClear = false; // hard limit of stored pixels (fallback limit of 10 MB) private _pixelLimit: number = 2500000; + private _workCell: CellData = new CellData(); private _viewportMetrics: { cols: number, rows: number }; public onImageAdded: (() => void) | undefined; @@ -271,10 +194,10 @@ export class ImageStorage implements IDisposable { this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); for (let row = 0; row < rows; ++row) { - const line = buffer.lines.get(buffer.y + buffer.ybase); + const line = buffer.lines.get(buffer.y + buffer.ybase)!; for (let col = 0; col < cols; ++col) { if (offset + col >= termCols) break; - this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col); + this._writeToCell(line, offset + col, imageId, row * cols + col); tileCount++; } if (scrolling) { @@ -417,22 +340,18 @@ export class ImageStorage implements IDisposable { const placeholderCalls: { col: number, row: number, count: number }[] = []; // walk all cells in viewport and collect tiles found - // Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag) + // Note: We check extended directly (not just HAS_EXTENDED flag) // because text writes clear the BG flag but leave image tile data intact. // This lets top-layer images survive text overwrites (kitty C=1 behavior). for (let row = start; row <= end; ++row) { - const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt; + const line = buffer.lines.get(row + buffer.ydisp); if (!line) return; + const workCell = this._workCell; for (let col = 0; col < cols; ++col) { - let e: IExtendedAttrsImage; - if (line.getBg(col) & BgFlags.HAS_EXTENDED) { - e = line._extendedAttrs[col] ?? EMPTY_ATTRS; - } else { - const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) { - continue; - } - e = maybeImg; + line.loadCell(col, workCell); + let e = workCell.hasExtendedAttrs() && workCell.extended.payload; + if (! (e instanceof ImageTileInfo)) { + continue; } const imageId = e.imageId; if (imageId === undefined || imageId === -1) { @@ -451,8 +370,9 @@ export class ImageStorage implements IDisposable { * Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. */ while (++col < cols) { - const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { + line.loadCell(col, workCell); + const nextE = workCell.hasExtendedAttrs() && workCell.extended.payload; + if (! (nextE instanceof ImageTileInfo) || !nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { break; } e = nextE; @@ -503,10 +423,15 @@ export class ImageStorage implements IDisposable { const buffer = this._terminal._core.buffer; const rows = buffer.lines.length; const oldCol = this._viewportMetrics.cols - 1; + const workCell = this._workCell; for (let row = 0; row < rows; ++row) { - const line = buffer.lines.get(row) as IBufferLineExt; - if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS; + const line = buffer.lines.get(row) !; + line.loadCell(oldCol, workCell); + if (workCell.hasExtendedAttrs()) { + const e = workCell.extended.payload; + if (! (e instanceof ImageTileInfo)) { + continue; + } const imageId = e.imageId; if (imageId === undefined || imageId === -1) { continue; @@ -523,7 +448,7 @@ export class ImageStorage implements IDisposable { // expand only if right side is empty (nothing got wrapped from below) let hasData = false; for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) { - if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) { + if (line.hasContent(rightCol)) { hasData = true; break; } @@ -535,7 +460,7 @@ export class ImageStorage implements IDisposable { const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); let lastTile = e.tileId; for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { - this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile); + this._writeToCell(line, expandCol, imageId, ++lastTile); imgSpec.tileCount++; } } @@ -549,10 +474,10 @@ export class ImageStorage implements IDisposable { */ public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; - const line = buffer.lines.get(y) as IBufferLineExt; - if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS; - if (e.imageId && e.imageId !== -1) { + const line = buffer.lines.get(y); + if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { + const e = this._workCell.extended.payload; + if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) { const orig = this._images.get(e.imageId)?.orig; if (window.ImageBitmap && orig instanceof ImageBitmap) { const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height); @@ -569,10 +494,10 @@ export class ImageStorage implements IDisposable { */ public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; - const line = buffer.lines.get(y) as IBufferLineExt; - if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS; - if (e.imageId && e.imageId !== -1 && e.tileId !== -1) { + const line = buffer.lines.get(y); + if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { + const e = this._workCell.extended.payload; + if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) { const spec = this._images.get(e.imageId); if (spec) { return this._renderer.extractTile(spec, e.tileId); @@ -600,31 +525,34 @@ export class ImageStorage implements IDisposable { return used - current; } - private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void { - if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - const old = line._extendedAttrs[x]; - if (old) { - if (old.imageId !== undefined) { - // found an old ExtendedAttrsImage, since we know that - // they are always isolated instances (single cell usage), - // we can re-use it and just update their id entries - const oldSpec = this._images.get(old.imageId); - if (oldSpec) { - // early eviction for in-viewport overwrites - oldSpec.tileCount--; - } - old.imageId = imageId; - old.tileId = tileId; - return; + private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void { + const workCell = this._workCell; + line.loadCell(x, workCell); + if (this._workCell.hasExtendedAttrs()) { + const old = workCell.extended.payload; + if (old instanceof ImageTileInfo) { + // found an old ExtendedAttrsImage, since we know that + // they are always isolated instances (single cell usage), + // we can re-use it and just update their id entries + const oldSpec = this._images.get(old.imageId); + if (oldSpec) { + // early eviction for in-viewport overwrites + oldSpec.tileCount--; } - // found a plain ExtendedAttrs instance, clone it to new entry - line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId); + old.imageId = imageId; + old.tileId = tileId; return; } + // found a plain ExtendedAttrs instance + workCell.extended.payload = new ImageTileInfo(imageId, tileId); + return; } // fall-through: always create new ExtendedAttrsImage entry - line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED; - line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId); + const extattr = workCell.extended.clone(); + extattr.payload = new ImageTileInfo(imageId, tileId); + workCell.extended = extattr; + workCell.updateExtended(); + line.setCell(x, workCell); } private _evictOnAlternate(): void { @@ -637,15 +565,17 @@ export class ImageStorage implements IDisposable { // re-count tiles on whole buffer const buffer = this._terminal._core.buffer; for (let y = 0; y < this._terminal.rows; ++y) { - const line = buffer.lines.get(y) as IBufferLineExt; + const line = buffer.lines.get(y); if (!line) { continue; } + const workCell = this._workCell; for (let x = 0; x < this._terminal.cols; ++x) { - if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - const imgId = line._extendedAttrs[x]?.imageId; - if (imgId) { - const spec = this._images.get(imgId); + line.loadCell(x, workCell); + if (workCell.hasExtendedAttrs()) { + const payload = workCell.extended.payload; + if (payload instanceof ImageTileInfo) { + const spec = this._images.get(payload.imageId); if (spec) { spec.tileCount++; } diff --git a/addons/addon-image/src/Types.ts b/addons/addon-image/src/Types.ts index 80cec9e243..675c9fdd61 100644 --- a/addons/addon-image/src/Types.ts +++ b/addons/addon-image/src/Types.ts @@ -9,7 +9,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm'; import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants'; import type { AttributeData } from 'common/buffer/AttributeData'; import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types'; -import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types'; +import type { IInputHandler } from 'common/Types'; import type { ITerminal, ReadonlyColorSet } from 'browser/Types'; import type { IRenderDimensions } from 'browser/renderer/shared/Types'; import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; @@ -47,6 +47,7 @@ export interface IResetHandler { reset(): void; } +/* eslint-disable */ /** * Stub into private interfaces. * This should be kept in line with common libs. @@ -54,19 +55,6 @@ export interface IResetHandler { * have a somewhat reliable testing against code changes in the core repo. */ -// overloaded IExtendedAttrs to hold image refs -export interface IExtendedAttrsImage extends IExtendedAttrs { - imageId: number; - tileId: number; - clone(): IExtendedAttrsImage; -} - -/* eslint-disable */ -export interface IBufferLineExt extends IBufferLine { - _extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined}; - _data: Uint32Array; -} - interface IInputHandlerExt extends IInputHandler { _parser: IEscapeSequenceParser; _curAttrData: AttributeData; @@ -77,6 +65,7 @@ interface IInputHandlerExt extends IInputHandler { }; onRequestReset(handler: () => void): IDisposable; } +/* eslint-enable */ export interface ICoreTerminalExt extends ITerminal { _themeService: IThemeService | undefined; @@ -88,8 +77,6 @@ export interface ICoreTerminalExt extends ITerminal { export interface ITerminalExt extends Terminal { _core: ICoreTerminalExt; } -/* eslint-enable */ - /** * Some storage definitions. diff --git a/src/common/Types.ts b/src/common/Types.ts index 7c7bbf4f5e..955b4fa1e3 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -114,6 +114,7 @@ export interface IExtendedAttrs { underlineColor: number; underlineVariantOffset: number; urlId: number; + payload: Object | undefined; clone(): IExtendedAttrs; isEmpty(): boolean; } diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 6221fb81d2..d81922105f 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -138,6 +138,7 @@ export class AttributeData implements IAttributeData { */ export class ExtendedAttrs implements IExtendedAttrs { private _ext: number = 0; + public payload: Object | undefined; public get ext(): number { if (this._urlId) { return ( @@ -206,6 +207,6 @@ export class ExtendedAttrs implements IExtendedAttrs { * that needs to be persistant in the buffer. */ public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0; + return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined; } } From 686035e1437875400ea93bd3983ae8fc1a002686 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 08:07:26 -0700 Subject: [PATCH 10/20] Remove no-longer working test for private accessors. --- addons/addon-image/test/ImageAddon.test.ts | 26 ---------------------- 1 file changed, 26 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index dc729ec63f..f996b8d769 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -98,32 +98,6 @@ test.describe('ImageAddon', () => { `); }); - test('test for private accessors', async () => { - // terminal privates - const accessors = [ - '_core', - '_core._renderService', - '_core._inputHandler', - '_core._inputHandler._parser', - '_core._inputHandler._curAttrData', - '_core._inputHandler._dirtyRowTracker', - '_core._themeService.colors', - '_core._coreBrowserService' - ]; - for (const prop of accessors) { - strictEqual( - await ctx.page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'), - true, `problem at ${prop}` - ); - } - // bufferline privates - strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true); - strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true); - // inputhandler privates - strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), '_AttributeData'); - strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser'); - }); - test.describe('ctor options', () => { test('empty settings should load defaults', async () => { const DEFAULT_OPTIONS: IImageAddonOptions = { From ed1c48a5f4df9284e040806de3be1a37546c98b4 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 10:25:28 -0700 Subject: [PATCH 11/20] Make BufferLine _logicalLine property be private. Add logical() accessor method. This makes _logicalLine not be enumerable, which should fix JSON cycles. --- src/common/InputHandler.ts | 2 +- src/common/Types.ts | 2 +- src/common/buffer/Buffer.ts | 18 +++---- src/common/buffer/BufferLine.ts | 65 ++++++++++++------------ src/common/buffer/Marker.ts | 2 +- src/common/services/BufferService.ts | 2 +- src/common/services/DecorationService.ts | 2 +- 7 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index e37ce6ca80..02e9852135 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1180,7 +1180,7 @@ export class InputHandler extends Disposable implements IInputHandler { const next = line.nextBufferLine; if (next) next.asUnwrapped(line); line.eraseRight(start); - line.logicalLine.backgroundColor = this._curAttrData.bg & ~0xFC000000; + line.logical().backgroundColor = this._curAttrData.bg & ~0xFC000000; } else { line.replaceCells( start, diff --git a/src/common/Types.ts b/src/common/Types.ts index 955b4fa1e3..846bb79c24 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -229,7 +229,7 @@ export interface ILogicalLine { * Interface for a line in the terminal buffer. */ export interface IBufferLine { - logicalLine: ILogicalLine; + logical(): ILogicalLine; startColumn: number; length: number; get isWrapped(): boolean; diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 111e9479de..ca416f4734 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -48,6 +48,7 @@ export class Buffer extends Disposable implements IBuffer { * Lines later in the buffer are more likly to be visible and hence * have been updated. */ public lastReflowNeeded: number = 0; + /** * This is an expensive operation. * @deprecated @@ -57,15 +58,14 @@ export class Buffer extends Disposable implements IBuffer { const nlines = this.lines.length; for (let i = 0; i < nlines; i++) { const bline = this.lines.get(i) as BufferLine; - const lline = bline.logicalLine; + const lline = bline.logical(); if (lline.firstBufferLine === bline) { - for (let m = lline._firstMarker; m; m = m._nextMarker) { - mm.push(m); - } + lline.forEachMarker((m) => { mm.push(m as Marker);}); } } return mm; } + private _nullCell: ICellData = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); private _whitespaceCell: ICellData = CellData.fromCharData([0, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_WIDTH, WHITESPACE_CELL_CODE]); private _cols: number; @@ -225,7 +225,7 @@ export class Buffer extends Disposable implements IBuffer { const ydispOld = this.ydisp; if (newRows < this._rows || newCols < this._cols) { const minHeight = Math.max(this.savedY, this.ybase + this.y) + 1; - while (this.lines.length > minHeight && this.lines.get(this.lines.length - 1)?.logicalLine.isEmpty()) { + while (this.lines.length > minHeight && this.lines.get(this.lines.length - 1)?.logical().isEmpty()) { this.lines.pop(); } } @@ -235,7 +235,7 @@ export class Buffer extends Disposable implements IBuffer { for (let i = 0; i < nlines; i++) { const line = this.lines.get(i) as BufferLine; line.length = newCols; - const logical = line.logicalLine; + const logical = line.logical(); if (! line.isWrapped) { if (line.nextBufferLine || logical.length > newCols) { logical.reflowNeeded = true; @@ -346,7 +346,7 @@ export class Buffer extends Disposable implements IBuffer { let ySaved = ySavedOld; if (! reflowCursorLine && yAbs >= 0 && yAbs < this.lines.length) { const cursorLine = this.lines.get(yAbsOld) as BufferLine; - cursorLine.logicalLine.reflowNeeded = false; + cursorLine.logical().reflowNeeded = false; } let deltaSoFar = 0; for (let row = startRow; row < endRow;) { @@ -356,7 +356,7 @@ export class Buffer extends Disposable implements IBuffer { } const line = this.lines.get(row) as BufferLine; newLines.push(line); - const logical = line.logicalLine; + const logical = line.logical(); if (line === logical.firstBufferLine && logical.reflowNeeded) { let curLine: BufferLine = line; @@ -557,7 +557,7 @@ export class Buffer extends Disposable implements IBuffer { public addMarker(y: number, x?: number, marker?: Marker): Marker { const bline = this.lines.get(y) as BufferLine; - const lline = bline.logicalLine; + const lline = bline.logical(); const m = marker ?? new Marker(); m.addToLine(this, lline, x ?? bline.startColumn); return m; diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 2af876fe14..0a60de21e6 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -347,7 +347,8 @@ export class LogicalLine implements ILogicalLine { * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ export class BufferLine implements IBufferLine { - public logicalLine: LogicalLine; + private _logicalLine: LogicalLine; + public logical(): LogicalLine { return this._logicalLine; } public nextBufferLine: BufferLine | undefined; protected _stringCacheEntryRef: WeakRef | undefined; @@ -369,20 +370,20 @@ export class BufferLine implements IBufferLine { * @internal */ public get validEnd(): LogicalColumn { - return this.nextBufferLine ? this.nextBufferLine.startColumn : this.logicalLine.length; + return this.nextBufferLine ? this.nextBufferLine.startColumn : this._logicalLine.length; } constructor(protected readonly _stringCache: IBufferLineStringCache, cols: number, logicalLine = new LogicalLine(cols) ) { - this.logicalLine = logicalLine; + this._logicalLine = logicalLine; this.length = cols; logicalLine.firstBufferLine ??= this; } public get isWrapped(): boolean { - return this.logicalLine.firstBufferLine !== this; + return this._logicalLine.firstBufferLine !== this; } /** @@ -390,7 +391,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public get(index: BufferColumn): CharData { - const lline = this.logicalLine; + const lline = this._logicalLine; const lindex: LogicalColumn = index + this.startColumn; if (lindex >= this.validEnd) { return [0, '', NULL_CELL_WIDTH, 0]; @@ -424,7 +425,7 @@ export class BufferLine implements IBufferLine { public getWidth(index: number): number { const lindex: LogicalColumn = index + this.startColumn; return lindex >= this.validEnd ? NULL_CELL_WIDTH - : this.logicalLine.getWidth(lindex); + : this._logicalLine.getWidth(lindex); } /** Test whether content has width. */ @@ -434,7 +435,7 @@ export class BufferLine implements IBufferLine { /** Get FG cell component. */ public getFg(index: number): number { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn = index + this.startColumn; return lcolumn >= this.validEnd ? 0 : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.FG]; } @@ -442,7 +443,7 @@ export class BufferLine implements IBufferLine { /** Get BG cell component. */ public getBg(index: number): number { index += this.startColumn; - const lline = this.logicalLine; + const lline = this._logicalLine; return index > lline.length ? lline.backgroundColor : lline._data[index * Constants.CELL_INDICIES + Cell.BG]; } @@ -457,7 +458,7 @@ export class BufferLine implements IBufferLine { if (index >= this.validEnd) { return 0; } - const lline = this.logicalLine; + const lline = this._logicalLine; return lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } @@ -467,7 +468,7 @@ export class BufferLine implements IBufferLine { * a single UTF32 codepoint or the last codepoint of a combined string. */ public getCodePoint(index: BufferColumn): number { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn: LogicalColumn = index + this.startColumn; if (lcolumn >= this.validEnd) { return 0; @@ -482,7 +483,7 @@ export class BufferLine implements IBufferLine { /** Test whether the cell contains a combined string. */ public isCombined(index: number): number { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn: LogicalColumn = index + this.startColumn; if (lcolumn >= this.validEnd) { return 0; @@ -492,7 +493,7 @@ export class BufferLine implements IBufferLine { /** Returns the string content of the cell. */ public getString(index: number): string { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn: LogicalColumn = index + this.startColumn; if (lcolumn >= this.validEnd) { return ''; @@ -510,7 +511,7 @@ export class BufferLine implements IBufferLine { /** Get state of protected flag. */ public isProtected(index: number): number { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn = index + this.startColumn; return index >= this.length || lcolumn >= lline.length ? 0 : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; @@ -521,7 +522,7 @@ export class BufferLine implements IBufferLine { * to GC as it significantly reduced the amount of new objects/references needed. */ public loadCell(index: number, cell: ICellData): ICellData { - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn = index + this.startColumn; const lend = this.validEnd; if (lcolumn >= lend) { @@ -546,7 +547,7 @@ export class BufferLine implements IBufferLine { const content = cell.content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); this.setCellFromCodepoint(index, content, cell.getWidth(), cell); if (cell.content & Content.IS_COMBINED_MASK) { - this.logicalLine._combined[index + this.startColumn] = cell.combinedData; + this._logicalLine._combined[index + this.startColumn] = cell.combinedData; } } @@ -557,7 +558,7 @@ export class BufferLine implements IBufferLine { */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { this._invalidateStringCache(); - this.logicalLine.setCellFromCodepoint(index + this.startColumn, + this._logicalLine.setCellFromCodepoint(index + this.startColumn, codePoint, width, attrs); } @@ -569,7 +570,7 @@ export class BufferLine implements IBufferLine { */ public addCodepointToCell(index: number, codePoint: number, width: number): void { this._invalidateStringCache(); - const lline = this.logicalLine; + const lline = this._logicalLine; const lcolumn = index + this.startColumn; if (lcolumn >= this.validEnd) { // should not happen - we actually have no data in the cell yet @@ -694,7 +695,7 @@ export class BufferLine implements IBufferLine { * @internal */ public clearMarkers(): void { - const lline = this.logicalLine; + const lline = this._logicalLine; const startColumn = this.startColumn; const endColumn = this.nextBufferLine ? this.nextBufferLine.startColumn : Infinity; for (let m = lline._firstMarker; m; m = m._nextMarker) { @@ -715,7 +716,7 @@ export class BufferLine implements IBufferLine { */ public resize(cols: number, fillCellData: ICellData): boolean { this._invalidateStringCache(); - const logical = this.logicalLine; + const logical = this._logicalLine; if (logical.firstBufferLine !== this || this.nextBufferLine) { throw new Error('invalid call to resize'); } @@ -759,7 +760,7 @@ export class BufferLine implements IBufferLine { * Returns 0 or 1 indicating whether a cleanup happened. */ public cleanupMemory(): number { - return this.logicalLine.cleanupMemory(Constants.CLEANUP_THRESHOLD); + return this._logicalLine.cleanupMemory(Constants.CLEANUP_THRESHOLD); } /** fill a line with fillCharData */ @@ -774,7 +775,7 @@ export class BufferLine implements IBufferLine { } return; } - const lline = this.logicalLine; + const lline = this._logicalLine; if (lline.firstBufferLine === this && ! this.nextBufferLine) { lline._combined = {}; lline._extendedAttrs = {}; @@ -793,7 +794,7 @@ export class BufferLine implements IBufferLine { } public getTrimmedLength(noBg: boolean = false): number { - const logicalLine = this.logicalLine; + const logicalLine = this._logicalLine; const startColumn = this.startColumn; const data = logicalLine._data; for (let i = this.validEnd; --i >= startColumn; ) { @@ -807,7 +808,7 @@ export class BufferLine implements IBufferLine { } public getNoBgTrimmedLength(): number { - if (this.logicalLine.backgroundColor) { + if (this._logicalLine.backgroundColor) { return this.length; } return this.getTrimmedLength(true); @@ -815,12 +816,12 @@ export class BufferLine implements IBufferLine { public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { this._invalidateStringCache(); - this.logicalLine.copyCellsFrom(src.logicalLine, srcCol + src.startColumn, + this._logicalLine.copyCellsFrom(src._logicalLine, srcCol + src.startColumn, destCol + this.startColumn, length, applyInReverse); } public getPreviousLine(): BufferLine | undefined { - for (let row = this.logicalLine.firstBufferLine; ;) { + for (let row = this._logicalLine.firstBufferLine; ;) { if (! row) { return undefined; } @@ -836,7 +837,7 @@ export class BufferLine implements IBufferLine { this._invalidateStringCache(); const lineStart = this.startColumn; const lineEnd = lineStart + index; - const lline = this.logicalLine; + const lline = this._logicalLine; if (this.nextBufferLine) { const oldEnd = this.nextBufferLine.startColumn; const count = oldEnd - lineEnd; @@ -859,8 +860,8 @@ export class BufferLine implements IBufferLine { public setWrapped(previousLine: BufferLine): BufferLine { const column = previousLine.startColumn + previousLine.length; - const logicalLine = previousLine.logicalLine; - const oldLogical = this.logicalLine; + const logicalLine = previousLine._logicalLine; + const oldLogical = this._logicalLine; logicalLine.resizeData(column + oldLogical.length); const newData = logicalLine._data; for (let i = logicalLine.length; i < column + oldLogical.length; i++) { @@ -903,7 +904,7 @@ export class BufferLine implements IBufferLine { previousLine.nextBufferLine = this; for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { line.startColumn += column; - line.logicalLine = logicalLine; + line._logicalLine = logicalLine; } return this; @@ -912,7 +913,7 @@ export class BufferLine implements IBufferLine { public asUnwrapped(prevRow: BufferLine): LogicalLine { const oldStartColumn = this.startColumn; prevRow.nextBufferLine = undefined; - const oldLine = prevRow.logicalLine; + const oldLine = prevRow._logicalLine; const cell = new CellData(); this.loadCell(oldStartColumn, cell); const newLength = oldLine.length - oldStartColumn; @@ -921,7 +922,7 @@ export class BufferLine implements IBufferLine { newLogical.firstBufferLine = this; for (let nextRow: BufferLine | undefined = this; nextRow; nextRow = nextRow.nextBufferLine) { nextRow.startColumn -= oldStartColumn; - nextRow.logicalLine = newLogical; + nextRow._logicalLine = newLogical; } let prevMarker: Marker | undefined; // in oldLine marker list let newMarkerLast: Marker | undefined; // in newLogical marker list @@ -979,7 +980,7 @@ export class BufferLine implements IBufferLine { if (trimRight) { endCol = Math.min(endCol, this.getTrimmedLength()); } - const lline = this.logicalLine; + const lline = this._logicalLine; const lineStart = this.startColumn; const validEnd = this.validEnd; startCol += lineStart; diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index 32e2d3c893..5e36c1323b 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -65,7 +65,7 @@ export class Marker implements IMarker { const nlines = buffer.lines.length; let prevLine: LogicalLine | undefined; for (let i: number = 0; i < nlines; i++) { - const lline = (buffer.lines.get(i) as BufferLine).logicalLine; + const lline = (buffer.lines.get(i) as BufferLine).logical(); if (lline !== prevLine) { for (let m = lline._firstMarker; m; m = m._nextMarker) { if (m === this) { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 342a6a2f93..8d8610e554 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -74,7 +74,7 @@ export class BufferService extends Disposable implements IBufferService { const oldLine = buffer.lines.get(bottomRow) as BufferLine; let lline: LogicalLine; if (isWrapped) { - lline = oldLine.logicalLine; + lline = oldLine.logical(); } else { lline = new LogicalLine(0); } diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 30acbfa02e..8b2ebdaf74 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -96,7 +96,7 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, bline: IBufferLine): void { - const lline = bline.logicalLine; + const lline = bline.logical(); x += bline.startColumn; lline.forEachMarker((marker: IMarker) => { const d = marker.payload; From 3f7db7911d887a1271a888e3a4dd9dfab47530b1 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 14:44:25 -0700 Subject: [PATCH 12/20] Update inspectBuffer in serialize testing for LogicalLine --- .../test/SerializeAddon.test.ts | 22 ++++++++++++------- src/common/buffer/BufferLine.ts | 1 - 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index fb42d807dc..eb68bdb4de 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -49,14 +49,20 @@ test.describe('SerializeAddon', () => { for (let i = 0; i < buffer.length; i++) { // Do this intentionally to get content of underlining source const bufferLine = buffer.getLine(i)._line; - lines.push(JSON.stringify(bufferLine, (key, value) => { - // BufferLine caches are internal/transient and can legitimately differ - // across equivalent terminal states. - if (key === '_stringCache' || key === '_stringCacheEntryRef') { - return undefined; - } - return value; - })); + if (bufferLine.isWrapped) { + lines.push({ startColumn: bufferLine.startColumn, length: bufferLine.length}); + } else { + const logical = bufferLine.logical(); + lines.push(JSON.stringify(logical, (key, value) => { + if (key === 'firstBufferLine' ) { + return undefined; + } + if (key === '_data') { + return new Uint32Array(value.buffer, 0, (logical as any).length * 3); + } + return value; + })); + } } return { x: buffer.cursorX, diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 0a60de21e6..a2223034c3 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -200,7 +200,6 @@ export class LogicalLine implements ILogicalLine { return; } if (index >= this.length) { - if ((this as any).xyz) { console.log('-set fill '+index+' to '+this.length);} this.resizeData(index + 1); for (let i = this.length; i < index; i++) { this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; From baf17949195b867b595cb4107e92892c9f7bb794 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 14:50:05 -0700 Subject: [PATCH 13/20] Tweak to previous checkin. --- addons/addon-serialize/test/SerializeAddon.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index eb68bdb4de..956babcad0 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -58,7 +58,7 @@ test.describe('SerializeAddon', () => { return undefined; } if (key === '_data') { - return new Uint32Array(value.buffer, 0, (logical as any).length * 3); + return new Uint32Array(value.buffer, 0, logical.length * 3); } return value; })); From f15c97422c4d05bfac6e936717b318120b59bbbd Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 15:05:39 -0700 Subject: [PATCH 14/20] Remove a debug remnant. --- src/common/buffer/Marker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index 5e36c1323b..c1aa265ea8 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -85,7 +85,6 @@ export class Marker implements IMarker { } public dispose(): void { - if ((globalThis as any).xyz && this.id===27) console.trace('dispose M:'+this.id); if (this.isDisposed) { return; } From 41ec6d3bd4a63e37d7c286ac4411ca2d93db16ee Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 18 May 2026 16:16:23 -0700 Subject: [PATCH 15/20] New new forEachDecorationAtCellLine to speed up webgl decorator handling --- addons/addon-webgl/src/CellColorResolver.ts | 12 ++++++------ addons/addon-webgl/src/WebglRenderer.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index 3a4661cc31..9d8ed47c8b 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -3,7 +3,7 @@ import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { ReadonlyColorSet } from 'browser/Types'; import { Attributes, BgFlags, ExtFlags, FgFlags, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants'; import { IDecorationService, IOptionsService } from 'common/services/Services'; -import { ICellData } from 'common/Types'; +import { ICellData, IBufferLine } from 'common/Types'; import { Terminal } from '@xterm/xterm'; import { rgba } from 'common/Color'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; @@ -43,7 +43,7 @@ export class CellColorResolver { * Resolves colors for the cell, putting the result into the shared {@link result}. This resolves * overrides, inverse and selection for the cell which can then be used to feed into the renderer. */ - public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { + public resolve(line: IBufferLine, cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -68,7 +68,7 @@ export class CellColorResolver { $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); } // Apply decorations on the bottom layer - this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { + this._decorationService.forEachDecorationAtCellLine(x, y, 'bottom', d => { if (d.backgroundColorRGB) { $bg = d.backgroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasBg = true; @@ -77,7 +77,7 @@ export class CellColorResolver { $fg = d.foregroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasFg = true; } - }); + }, line); // Apply the selection color if needed $isSelected = this._selectionRenderModel.isCellSelected(this._terminal, x, y); @@ -175,7 +175,7 @@ export class CellColorResolver { } // Apply decorations on the top layer - this._decorationService.forEachDecorationAtCell(x, y, 'top', d => { + this._decorationService.forEachDecorationAtCellLine(x, y, 'top', d => { if (d.backgroundColorRGB) { $bg = d.backgroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasBg = true; @@ -184,7 +184,7 @@ export class CellColorResolver { $fg = d.foregroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasFg = true; } - }); + }, line); // Convert any overrides from rgba to the fg/bg packed format. This resolves the inverse flag // ahead of time in order to use the correct cache key diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 91867ea261..18a73075ce 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -500,7 +500,7 @@ export class WebglRenderer extends Disposable implements IRenderer { } // Load colors/resolve overrides into work colors - this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); + this._cellColorResolver.resolve(line, cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { From d04931148b00e9ac86c42ce0b8db7023daf32828 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 22 May 2026 21:43:09 -0700 Subject: [PATCH 16/20] Fix Marker.line slowdown. --- .../decorations/BufferDecorationRenderer.ts | 6 +- src/common/buffer/Buffer.ts | 68 +++++++++++++++++-- src/common/buffer/BufferLine.ts | 1 + src/common/buffer/Marker.ts | 48 ++++--------- 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/src/browser/decorations/BufferDecorationRenderer.ts b/src/browser/decorations/BufferDecorationRenderer.ts index e374af2609..94091c5dce 100644 --- a/src/browser/decorations/BufferDecorationRenderer.ts +++ b/src/browser/decorations/BufferDecorationRenderer.ts @@ -69,13 +69,13 @@ export class BufferDecorationRenderer extends Disposable { } } - private _createElement(decoration: IInternalDecoration): HTMLElement { + private _createElement(decoration: IInternalDecoration, line: number): HTMLElement { const element = this._coreBrowserService.mainDocument.createElement('div'); element.classList.add('xterm-decoration'); element.classList.toggle('xterm-decoration-top-layer', decoration?.options?.layer === 'top'); element.style.width = `${Math.round((decoration.options.width || 1) * this._renderService.dimensions.css.cell.width)}px`; element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.css.cell.height}px`; - element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`; + element.style.top = `${(line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`; element.style.lineHeight = `${this._renderService.dimensions.css.cell.height}px`; const x = decoration.options.x ?? 0; @@ -99,7 +99,7 @@ export class BufferDecorationRenderer extends Disposable { } else { let element = this._decorationElements.get(decoration); if (!element) { - element = this._createElement(decoration); + element = this._createElement(decoration, line); decoration.element = element; this._decorationElements.set(decoration, element); this._container.appendChild(element); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index ca416f4734..9822be10bc 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -91,11 +91,18 @@ export class Buffer extends Disposable implements IBuffer { for (let i = 0; i < amount; i++) { this.clearMarkers(i); } - const first = this.lines.length && this.lines.get(amount); + const first = amount < this.lines.length && this.lines.get(amount); if (first instanceof BufferLine && first.isWrapped) { const prev = first.getPreviousLine(); prev && first.asUnwrapped(prev); - }}); + } + if (first instanceof BufferLine && first._voffset < 0 && amount > 0) { + const line0 = this.lines.get(0); + if (line0 instanceof BufferLine && line0._voffset >= 0) { + first._voffset = line0._voffset + amount; + } + } + }); this.lines.onDelete(event => { for (let i = event.amount; --i >= 0; ) { this.clearMarkers(event.index + i); @@ -152,6 +159,20 @@ export class Buffer extends Disposable implements IBuffer { return (relativeY >= 0 && relativeY < this._rows); } + public lineNumberOf(line: BufferLine): number { + const nlines = this.lines.length; + const line0 = nlines > 0 && this.lines.get(0); + if (line0 instanceof BufferLine) { + if (line._voffset < 0 || line0._voffset < 0) { + for (let i = 0; i < nlines; i++) { + (this.lines.get(i) as BufferLine)._voffset = i; + } + } + return line._voffset - line0._voffset; + } + return -1; + } + /** * Gets the correct buffer length based on the rows provided, the terminal's * scrollback and whether this buffer is flagged to have scrollback or not. @@ -458,6 +479,15 @@ export class Buffer extends Disposable implements IBuffer { this.ydisp = Math.max(0, this.ydisp - trimmedCount); this.y = yAbs - this.ybase; this.savedY = ySaved; + if (deltaSoFar !== 0) { + const nlines = this.lines.length; + let prevOffset = startRow <= 0 ? -1 + : (this.lines.get(startRow - 1) as BufferLine)._voffset; + if (prevOffset < 0) { startRow = 0; } + for (let row = startRow; row < nlines; row++) { + (this.lines.get(row) as BufferLine)._voffset = ++prevOffset; + } + } } /** @@ -555,11 +585,35 @@ export class Buffer extends Disposable implements IBuffer { this._isClearing = false; } - public addMarker(y: number, x?: number, marker?: Marker): Marker { + public addMarker(y: number, x?: number, m?: Marker): Marker { const bline = this.lines.get(y) as BufferLine; - const lline = bline.logical(); - const m = marker ?? new Marker(); - m.addToLine(this, lline, x ?? bline.startColumn); - return m; + if (bline._voffset < 0) { + const line0offset = (this.lines.get(0) as BufferLine)._voffset; + if (line0offset >= 0) { + bline._voffset = y + line0offset; + } + } + const marker = m ?? new Marker(); + marker.addToLine(this, bline, x ?? bline.startColumn); + + marker.register(this.lines.onInsert(event => { + const line = marker._lineData; + if (marker.line >= event.index && line instanceof BufferLine && line._voffset >= 0) { + line._voffset += event.amount; + } + })); + marker.register(this.lines.onDelete(event => { + // Delete the marker if it's within the range + if (marker.line >= event.index && marker.line < event.index + event.amount) { + marker.dispose(); + } + const line = marker._lineData; + // Shift the marker if it's after the deleted range + if (marker.line > event.index && line instanceof BufferLine && line._voffset >= 0) { + line._voffset = Math.max(-1, line._voffset - event.amount); + } + })); + + return marker; } } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index a2223034c3..6e8e46b970 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -350,6 +350,7 @@ export class BufferLine implements IBufferLine { public logical(): LogicalLine { return this._logicalLine; } public nextBufferLine: BufferLine | undefined; protected _stringCacheEntryRef: WeakRef | undefined; + public _voffset: number = -1; /** Number of logical columns in previous rows. * Also: logical column number (column number assuming infinitely-wide diff --git a/src/common/buffer/Marker.ts b/src/common/buffer/Marker.ts index c1aa265ea8..2ff778896d 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -6,13 +6,14 @@ import { IDisposable, IMarker } from 'common/Types'; import { Emitter } from 'common/Event'; import { Buffer } from 'common/buffer/Buffer'; -import { BufferLine, LogicalLine, LogicalColumn } from 'common/buffer/BufferLine'; +import { BufferLine, LogicalColumn } from 'common/buffer/BufferLine'; import { dispose } from 'common/Lifecycle'; export class Marker implements IMarker { public payload?: IDisposable; private _buffer: Buffer | undefined; - private _lineData: LogicalLine | undefined; + /** @internal */ + public _lineData: BufferLine | undefined; /** * @internal */ @@ -43,45 +44,23 @@ export class Marker implements IMarker { private readonly _onDispose = this.register(new Emitter()); public readonly onDispose = this._onDispose.event; - public addToLine(buffer: Buffer, line: LogicalLine, startColumn: LogicalColumn): void { + public addToLine(buffer: Buffer, bline: BufferLine, startColumn: LogicalColumn): void { this._buffer = buffer; - this._lineData = line; + this._lineData = bline; + const lline = bline.logical(); this._startColumn = startColumn; - this._nextMarker = line._firstMarker; - line._firstMarker = this; + this._nextMarker = lline._firstMarker; + lline._firstMarker = this; } /** * Get corresponding line number. - * This uses an expensive linear search through the buffer, so should be avoided. - * @deprecated * */ public get line(): number { - const buffer = this._buffer; - if (! buffer) { - return -1; - } - const nlines = buffer.lines.length; - let prevLine: LogicalLine | undefined; - for (let i: number = 0; i < nlines; i++) { - const lline = (buffer.lines.get(i) as BufferLine).logical(); - if (lline !== prevLine) { - for (let m = lline._firstMarker; m; m = m._nextMarker) { - if (m === this) { - let bline = lline.firstBufferLine; - for (let j = 0; ; j++) { - if (! bline || this._startColumn >= bline.startColumn) { - return i + j; - } - bline = bline?.nextBufferLine; - } - } - } - prevLine = lline; - } - } - return -1; + return this._buffer && this._lineData + ? this._buffer.lineNumberOf(this._lineData) + : -1; } public dispose(): void { @@ -100,10 +79,11 @@ export class Marker implements IMarker { } public removeMarker(): void { - const lline = this._lineData; - if (! lline) { + const bline = this._lineData; + if (! bline) { return; } + const lline = bline.logical(); let prev: Marker | undefined; for (let m = lline._firstMarker; m; ) { const next = m._nextMarker; From 3c6b153668e83c4ebb5ae978963d5c5a4e5c8b0d Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 23 May 2026 13:19:11 -0700 Subject: [PATCH 17/20] For decorations, use a plain doubly-linked list instead of SortedList. --- src/common/services/DecorationService.ts | 63 +++++++++++++++++------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 8b2ebdaf74..4631bf2036 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -6,7 +6,6 @@ import { css } from 'common/Color'; import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; -import { SortedList } from 'common/SortedList'; import { IColor, IBufferLine } from 'common/Types'; import { Marker } from 'common/buffer/Marker'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; @@ -21,25 +20,33 @@ let $ymax = 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; - private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); public readonly onDecorationRemoved = this._onDecorationRemoved.event; - public get decorations(): IterableIterator { return this._decorations.values(); } + public get decorations(): IterableIterator { + const iterator = { + current: this._firstDecoration, + next: (): IteratorResult => { + const node = iterator.current; + if (node) { + iterator.current = node.nextDecoration; + return { done: false, value: node }; + } + return { done: true, value: undefined }; + }, + [Symbol.iterator]: () => { + return iterator; } + }; + return iterator; + } + private _firstDecoration: Decoration | undefined; + private _lastDecoration: Decoration | undefined; constructor(@ILogService private readonly _logService: ILogService) { super(); - this._decorations = new SortedList(e => e?.marker.line, this._logService); - this._register(toDisposable(() => this.reset())); } @@ -53,23 +60,40 @@ export class DecorationService extends Disposable implements IDecorationService const listener = decoration.onDispose(() => { listener.dispose(); if (decoration) { - if (this._decorations.delete(decoration)) { - this._onDecorationRemoved.fire(decoration); + // Remove from linked list + const previous = decoration.previousDecoration; + const next = decoration.nextDecoration; + if (previous) { + previous.nextDecoration = next; + } else { + this._firstDecoration = next; + } + if (next) { + next.previousDecoration = previous; + } else { + this._lastDecoration = previous; } + this._onDecorationRemoved.fire(decoration); markerDispose.dispose(); } }); - this._decorations.insert(decoration); + // insert decoration into linked list + decoration.previousDecoration = this._lastDecoration; + if (this._lastDecoration) { + this._lastDecoration.nextDecoration = decoration; + } else { + this._firstDecoration = decoration; + } + this._lastDecoration = decoration; this._onDecorationRegistered.fire(decoration); } return decoration; } public reset(): void { - for (const d of this._decorations.values()) { + for (let d = this._firstDecoration; d; d = d.nextDecoration) { d.dispose(); } - this._decorations.clear(); } /** @@ -81,7 +105,7 @@ export class DecorationService extends Disposable implements IDecorationService let xmax = 0; let ymin = 0; let ymax = 0; - for (const d of this._decorations.values()) { + for (const d of this.decorations) { ymin = d.marker.line; ymax = ymin + (d.options.height ?? 1); if (line < ymin || line >= ymax) { @@ -111,7 +135,7 @@ 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()) { + for (let d = this._firstDecoration; d; d = d.nextDecoration) { $ymin = d.marker.line; $ymax = $ymin + (d.options.height ?? 1); if (line < $ymin || line >= $ymax) { @@ -126,9 +150,12 @@ export class DecorationService extends Disposable implements IDecorationService } } + class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + public nextDecoration: Decoration | undefined; + public previousDecoration: Decoration | undefined; public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; From 9702d787cd8c298029aa4cda702366264b9f2b38 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 23 May 2026 13:34:28 -0700 Subject: [PATCH 18/20] Remove no-longer-used SortedList.ts. --- src/common/SortedList.test.ts | 122 --------------------- src/common/SortedList.ts | 198 ---------------------------------- 2 files changed, 320 deletions(-) delete mode 100644 src/common/SortedList.test.ts delete mode 100644 src/common/SortedList.ts diff --git a/src/common/SortedList.test.ts b/src/common/SortedList.test.ts deleted file mode 100644 index 4718cf971b..0000000000 --- a/src/common/SortedList.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2018 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { SortedList } from 'common/SortedList'; -import { MockLogService } from 'common/TestUtils.test'; - -const deepStrictEqual = assert.deepStrictEqual; - -describe('SortedList', () => { - let list: SortedList; - function assertList(expected: number[]): void { - deepStrictEqual(Array.from(list.values()), expected); - } - - beforeEach(() => { - list = new SortedList(e => e, new MockLogService()); - }); - - describe('insert', () => { - it('should maintain sorted values', () => { - list.insert(10); - assertList([10]); - list.insert(8); - assertList([8, 10]); - list.insert(15); - assertList([8, 10, 15]); - list.insert(2); - assertList([2, 8, 10, 15]); - list.insert(1); - assertList([1, 2, 8, 10, 15]); - list.insert(6); - assertList([1, 2, 6, 8, 10, 15]); - }); - it('should allow duplicates of the same key', () => { - list.insert(5); - assertList([5]); - list.insert(5); - assertList([5, 5]); - list.insert(8); - assertList([5, 5, 8]); - list.insert(5); - assertList([5, 5, 5, 8]); - list.insert(8); - assertList([5, 5, 5, 8, 8]); - list.insert(6); - assertList([5, 5, 5, 6, 8, 8]); - }); - }); - it('delete', () => { - list.insert(1); - list.insert(2); - list.insert(4); - list.insert(3); - list.insert(5); - assertList([1, 2, 3, 4, 5]); - list.delete(1); - assertList([2, 3, 4, 5]); - list.delete(3); - assertList([2, 4, 5]); - list.delete(4); - assertList([2, 5]); - list.delete(5); - assertList([2]); - list.delete(2); - assertList([]); - }); - it('getKeyIterator', () => { - list.insert(5); - list.insert(5); - list.insert(8); - list.insert(5); - list.insert(8); - list.insert(6); - assertList([5, 5, 5, 6, 8, 8]); - deepStrictEqual(Array.from(list.getKeyIterator(1)), []); - deepStrictEqual(Array.from(list.getKeyIterator(5)), [5, 5, 5]); - deepStrictEqual(Array.from(list.getKeyIterator(6)), [6]); - deepStrictEqual(Array.from(list.getKeyIterator(8)), [8, 8]); - deepStrictEqual(Array.from(list.getKeyIterator(9)), []); - }); - it('clear', () => { - list.insert(1); - list.insert(2); - list.insert(4); - list.insert(3); - list.insert(5); - list.clear(); - assertList([]); - }); - it('custom key', () => { - const customList = new SortedList<{ key: number }>(e => e.key, new MockLogService()); - customList.insert({ key: 5 }); - customList.insert({ key: 2 }); - customList.insert({ key: 10 }); - customList.insert({ key: 5 }); - customList.insert({ key: 6 }); - deepStrictEqual(Array.from(customList.values()), [ - { key: 2 }, - { key: 5 }, - { key: 5 }, - { key: 6 }, - { key: 10 } - ]); - }); - describe('values', () => { - it('should iterate correctly when list items change during iteration', () => { - list.insert(1); - list.insert(2); - list.insert(3); - list.insert(4); - const visited: number[] = []; - for (const item of list.values()) { - visited.push(item); - list.delete(item); - } - deepStrictEqual(visited, [1, 2, 3, 4]); - }); - }); -}); diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts deleted file mode 100644 index c6dc62085c..0000000000 --- a/src/common/SortedList.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Copyright (c) 2022 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IdleTaskQueue } from 'common/TaskQueue'; -import type { ILogService } from 'common/services/Services'; - -// Work variables to avoid garbage collection. -let i = 0; - -/** - * 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 _array: T[] = []; - - private readonly _insertedValues: T[] = []; - private readonly _flushInsertedTask: InstanceType; - private _isFlushingInserted = false; - - private readonly _deletedIndices: number[] = []; - private readonly _flushDeletedTask: InstanceType; - private _isFlushingDeleted = false; - - constructor( - private readonly _getKey: (value: T) => number, - logService: ILogService - ) { - this._flushInsertedTask = new IdleTaskQueue(logService); - this._flushDeletedTask = new IdleTaskQueue(logService); - } - - 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 { - 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(); - } - } - - public delete(value: T): boolean { - this._flushCleanupInserted(); - if (this._array.length === 0) { - return false; - } - const key = this._getKey(value); - if (key === undefined) { - return false; - } - i = this._search(key); - if (i === -1) { - return false; - } - if (this._getKey(this._array[i]) !== key) { - return false; - } - do { - if (this._array[i] === value) { - 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; - } - i = this._search(key); - if (i < 0 || i >= this._array.length) { - return; - } - if (this._getKey(this._array[i]) !== key) { - return; - } - do { - yield this._array[i]; - } while (++i < this._array.length && this._getKey(this._array[i]) === key); - } - - public forEachByKey(key: number, callback: (value: T) => void): void { - this._flushCleanupInserted(); - this._flushCleanupDeleted(); - if (this._array.length === 0) { - return; - } - i = this._search(key); - if (i < 0 || i >= this._array.length) { - return; - } - if (this._getKey(this._array[i]) !== key) { - return; - } - do { - callback(this._array[i]); - } while (++i < this._array.length && this._getKey(this._array[i]) === key); - } - - public values(): IterableIterator { - this._flushCleanupInserted(); - this._flushCleanupDeleted(); - // Duplicate the array to avoid issues when _array changes while iterating - return [...this._array].values(); - } - - private _search(key: number): number { - let min = 0; - let max = this._array.length - 1; - while (max >= min) { - let mid = (min + max) >> 1; - const midKey = this._getKey(this._array[mid]); - if (midKey > key) { - max = mid - 1; - } else if (midKey < key) { - min = mid + 1; - } else { - // key in list, walk to lowest duplicate - while (mid > 0 && this._getKey(this._array[mid - 1]) === key) { - mid--; - } - return mid; - } - } - // key not in list - // still return closest min (also used as insert position) - return min; - } -} From 423af94636418b61e69dcf9726a6515236d7a3f8 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 21 May 2026 08:55:49 -0700 Subject: [PATCH 19/20] Use 'import type' in addons/addon-image/src/ImageStorage.ts Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- addons/addon-image/src/ImageStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index d7aa59dbf4..e9d5f8e8f4 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -6,7 +6,7 @@ import { IDisposable } from '@xterm/xterm'; import { ImageRenderer } from './ImageRenderer'; import { ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, ImageLayer } from './Types'; -import { IBufferLine } from 'common/Types'; +import type { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; From 3dbd3703faf5ac2fd02db31f641105bf9c5fb514 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 23 May 2026 16:42:09 -0700 Subject: [PATCH 20/20] Add 'type' to another import in ImageStorage.ts. --- addons/addon-image/src/ImageStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index e9d5f8e8f4..e22c247533 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -5,7 +5,7 @@ import { IDisposable } from '@xterm/xterm'; import { ImageRenderer } from './ImageRenderer'; -import { ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, ImageLayer } from './Types'; +import type { ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, ImageLayer } from './Types'; import type { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData';