From bd79ccc82acb8cd87756378bd6b30007c9fca310 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 6 Apr 2026 18:42:22 -0700 Subject: [PATCH 01/43] 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 b4696162353af198956b731240ebdd5ca0db8040 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 7 Jun 2026 13:32:46 -0700 Subject: [PATCH 02/43] Un-remove whitespace to match upsteam. --- src/common/buffer/Buffer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index ef079e8a5b..e6f8ae1750 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -364,12 +364,14 @@ export class Buffer extends Disposable 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) { 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 From 99250d1e04519e0f7fa38f63d726a5b34b40c104 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 13 Aug 2026 14:23:46 -0700 Subject: [PATCH 03/43] WIP - first sketch for using _chars array for combined chars. --- src/common/buffer/BufferLine.ts | 84 ++++++++++++++++++++------------- src/common/buffer/Constants.ts | 16 ++++++- 2 files changed, 66 insertions(+), 34 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 71d98492b9..9c4f3e65e5 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -75,8 +75,12 @@ export class LogicalLine implements ILogicalLine { * @internal */ public _data: Uint32Array; - /** Sparse cache; only rea`d when `IS_COMBINED_MASK` is set in `_data`. */ - public _combined: {[index: LogicalColumn]: string} = {}; + /** + * If charsIsTextValue is true: The text value of the line. + * If charsIsTextValue is false: Might only store combined character, + * and not necessarily in order. + */ + protected _chars: string = ''; /** * @internal */ @@ -90,6 +94,8 @@ export class LogicalLine implements ILogicalLine { * Must be no more than this._data.length / 3. */ public length: number = 0; + /** If _chars is the text value of this line. */ + protected _charsIsTextValue: boolean = false; constructor(cols: number = 0, data = new Uint32Array(cols * Constants.CELL_INDICIES)) { this._data = data; @@ -125,7 +131,7 @@ export class LogicalLine implements ILogicalLine { public charStart(column: LogicalColumn): number { return column > this.length ? this.length : column > 0 && this.getWidth(column - 1) > 1 ? column - 1 - : column; + : column; } /** @@ -143,7 +149,7 @@ export class LogicalLine implements ILogicalLine { 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]; + cell.combinedData = this.getString(index); } else { cell.combinedData = ''; } @@ -159,9 +165,14 @@ export class LogicalLine implements ILogicalLine { /** Returns the string content of the cell. */ public getString(index: number): string { + if (index >= this.length) { + return ''; + } const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; - if (content & Content.IS_COMBINED_MASK) { - return this._combined[index]; + if (content & Content.STORED_IN_CHARS_MASK) { + const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + return this._chars.substring(start, start + length); } if (content & Content.CODEPOINT_MASK) { return stringFromCodePoint(content & Content.CODEPOINT_MASK); @@ -170,6 +181,23 @@ export class LogicalLine implements ILogicalLine { return ''; } +/** + * Get codepoint of the cell. + * 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: BufferColumn): number { + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + if (content & Content.STORED_IN_CHARS_MASK) { + const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + return content & Content.IS_COMBINED_MASK + ? this._chars.charCodeAt(start + length - 1) + : (this._chars.codePointAt(start) ?? 0); + } + return content & Content.CODEPOINT_MASK; + } + /** Get state of protected flag. */ public isProtected(index: number): number { return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; @@ -220,6 +248,7 @@ export class LogicalLine implements ILogicalLine { const data = new Uint32Array(Constants.CELL_INDICIES * 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++) { @@ -228,6 +257,7 @@ export class LogicalLine implements ILogicalLine { delete this._combined[key]; } } + */ // remove any cut off extended attributes const extKeys = Object.keys(this._extendedAttrs); for (let i = 0; i < extKeys.length; i++) { @@ -303,17 +333,21 @@ export class LogicalLine implements ILogicalLine { } $translateToStringBuilder.reset(); while (startCol < endCol) { + const chars = this.getString(startCol); + /* const content = startCol >= dataLength ? 0 : this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; + */ $translateToStringBuilder.append(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 + startCol++; + // startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 } if (outColumns) { outColumns.push(startCol); @@ -343,7 +377,6 @@ export class BufferLine implements IBufferLine { private _logicalLine: LogicalLine; public logical(): LogicalLine { return this._logicalLine; } public nextBufferLine: BufferLine | undefined; - protected _stringCacheEntryRef: WeakRef | undefined; /** * Number of logical columns in previous rows. @@ -462,17 +495,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 lcolumn: LogicalColumn = index + this.startColumn; - if (lcolumn >= this.validEnd) { - return 0; - } - const content = lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; - if (content & Content.IS_COMBINED_MASK) { - const combined = lline._combined[lcolumn]; - return combined.charCodeAt(combined.length - 1); - } - return content & Content.CODEPOINT_MASK; + return this._logicalLine.getCodePoint(index + this.startColumn); } /** Test whether the cell contains a combined string. */ @@ -489,18 +512,7 @@ export class BufferLine implements IBufferLine { public getString(index: number): string { const lline = this._logicalLine; const lcolumn: LogicalColumn = index + this.startColumn; - if (lcolumn >= this.validEnd) { - return ''; - } - const content = lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; - if (content & Content.IS_COMBINED_MASK) { - return lline._combined[lcolumn]; - } - if (content & Content.CODEPOINT_MASK) { - return stringFromCodePoint(content & Content.CODEPOINT_MASK); - } - // return empty string for empty cells - return ''; + return lline.getString(lcolumn); } /** Get state of protected flag. */ @@ -536,12 +548,18 @@ export class BufferLine implements IBufferLine { */ public setCell(index: number, cell: ICellData): void { this._invalidateStringCache(); - // this.logicalLine.setCell(index + this.startColumn, cell); + 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._logicalLine._combined[index + this.startColumn] = cell.combinedData; + const start = this._chars.length; + this._chars += cell.combinedData; + content = content & ~(Content.START_IN_CHARS_MASK|Content.LENGTH_IN_CHARS_MASK) + | (start << Content.START_IN_CHARS_SHIFT) + | (cell.combinedData.length << Content.LENGTH_IN_CHARS_SHIFT); } + */ } /** diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index b5a6a4d4fa..6b71a27d0a 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -33,6 +33,15 @@ export const WHITESPACE_CELL_CODE = 32; /** * Bitmasks for accessing data in `content`. + * Text contrent is one of: + * (a) Directly stored in the content word as a 21-bit Unicode code point. + * In this case CODEPOINT_MASK contains the codepoint. + * STORED_IN_CHARS_MASK and IS_COMBINED_MASK are always 0 in this case. + * (b) A substring of the LogicalLine's _chars property. + * In this case STORED_IN_CHARS_MASK is non-zero. + * IS_COMBINED_MASK may be zero or non-zero. + * The first char is in START_IN_CHARS_MASK and the length is in + * LENGTH_IN_CHARS_MASK. */ export const enum Content { /** @@ -71,7 +80,12 @@ export const enum Content { * `content |= width << Content.WIDTH_SHIFT;` */ WIDTH_MASK = 0xC00000, // 3 << 22 - WIDTH_SHIFT = 22 + WIDTH_SHIFT = 22, + START_IN_CHARS_MASK = 0x1FFFFF, + START_IN_CHARS_SHIFT = 0, + LENGTH_IN_CHARS_MASK = 0xFF000000, + LENGTH_IN_CHARS_SHIFT = 24, + STORED_IN_CHARS_MASK = LENGTH_IN_CHARS_MASK } export const enum Attributes { From b1edb574c5846a4df0ea673f5c55f7cd45dd2b08 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 16 Aug 2026 11:13:09 -0700 Subject: [PATCH 04/43] Optimize _resizeData to need fewer (re-)allocations --- src/common/buffer/BufferLine.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 71d98492b9..11deef2ba1 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -67,6 +67,8 @@ export interface IBufferLineStringCache { touch?(): void; } +const EMPTY_DATA = new Uint32Array(0); + /* * The data "model" of a line ignoring line wrapping. */ @@ -75,7 +77,7 @@ export class LogicalLine implements ILogicalLine { * @internal */ public _data: Uint32Array; - /** Sparse cache; only rea`d when `IS_COMBINED_MASK` is set in `_data`. */ + /** Sparse cache; only read when `IS_COMBINED_MASK` is set in `_data`. */ public _combined: {[index: LogicalColumn]: string} = {}; /** * @internal @@ -91,7 +93,7 @@ export class LogicalLine implements ILogicalLine { */ public length: number = 0; - constructor(cols: number = 0, data = new Uint32Array(cols * Constants.CELL_INDICIES)) { + constructor(cols: number = 0, data = cols === 0 ? EMPTY_DATA : new Uint32Array(cols * Constants.CELL_INDICIES)) { this._data = data; } @@ -100,15 +102,11 @@ export class LogicalLine implements ILogicalLine { */ public resizeData(cols: number): void { const uint32Cells = cols * Constants.CELL_INDICIES; - 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); + const oldLength = this._data.length; + if (uint32Cells >= oldLength) { + // increase by at least 50% + const newLength = Math.max(uint32Cells + 60, (3 * oldLength) >> 1); + const data = new Uint32Array(newLength); data.set(this._data); this._data = data; } From ce06560125c7b777aad1ca3d424ff0bf02191b4b Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 24 Aug 2026 11:17:29 -0700 Subject: [PATCH 05/43] New LineBuffer.setCellsFromCodepoints This is used by InputHandler.print to "batch" multiple characters. --- src/common/InputHandler.ts | 129 ++++++++++++++++----------- src/common/buffer/Buffer.ts | 1 + src/common/buffer/BufferLine.ts | 37 ++++++++ src/common/services/BufferService.ts | 2 +- 4 files changed, 114 insertions(+), 55 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index c6426adaa5..2a4803a1bf 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -514,16 +514,20 @@ export class InputHandler extends Disposable implements IInputHandler { } } + /** + * Write printable characters. + * Character to write are code points stored in data.slice(start, end). + * Note that data.slice(start, end) might be modified in place. + */ public print(data: Uint32Array, start: number, end: number): void { let code: number; - let chWidth: number; const charset = this._charsetService.charset; const screenReaderMode = this._optionsService.rawOptions.screenReaderMode; const cols = this._bufferService.cols; const wraparoundMode = this._coreService.decPrivateModes.wraparound; const insertMode = this._coreService.modes.insertMode; const curAttr = this._curAttrData; - let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y); + let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y) as BufferLine; // Defensive check: bufferRow can be undefined if a resize occurred mid-write due to async // scheduling gaps in WriteBuffer. See https://github.com/xtermjs/xterm.js/issues/5597 @@ -539,43 +543,79 @@ export class InputHandler extends Disposable implements IInputHandler { } let precedingJoinState = this._parser.precedingJoinState; - for (let pos = start; pos < end; ++pos) { - code = data[pos]; - - // Soft hyphen's (U+00AD) behavior is ambiguous and differs across terminals. We opt to treat - // it as a zero-width hint to text layout engines and simply ignore it. - if (code === 0xAD) { - continue; - } + let pendingStart = -1; + let pendingCols = 0; + for (let pos = start; ; ++pos) { + let oldWidth; + let chWidth; + let shouldJoin; + let overflowing; + if (pos < end) { + code = data[pos]; + + // Soft hyphen's (U+00AD) behavior is ambiguous and differs across terminals. + // We opt to treat it as a zero-width hint to text layout engines and simply ignore it. + if (code === 0xAD) { + // Not very efficient, but simple. + pendingStart < 0 || data.copyWithin(pos, pos + 1, end); + continue; + } - // get charset replacement character - // charset is only defined for ASCII, therefore we only - // search for an replacement char if code < 127 - if (code < 127 && charset) { - const ch = charset[String.fromCharCode(code)]; - if (ch) { - code = ch.charCodeAt(0); + // get charset replacement character + // charset is only defined for ASCII, therefore we only + // search for an replacement char if code < 127 + if (code < 127 && charset) { + const ch = charset[String.fromCharCode(code)]; + if (ch) { + code = ch.charCodeAt(0); + data[pos] = code; + } } - } - const currentInfo = this._unicodeService.charProperties(code, precedingJoinState); - chWidth = UnicodeService.extractWidth(currentInfo); - const shouldJoin = UnicodeService.extractShouldJoin(currentInfo); - const oldWidth = shouldJoin ? UnicodeService.extractWidth(precedingJoinState) : 0; - precedingJoinState = currentInfo; + const currentInfo = this._unicodeService.charProperties(code, precedingJoinState); + chWidth = UnicodeService.extractWidth(currentInfo); + shouldJoin = UnicodeService.extractShouldJoin(currentInfo); + oldWidth = shouldJoin ? UnicodeService.extractWidth(precedingJoinState) : 0; + precedingJoinState = currentInfo; - if (screenReaderMode) { - this._onA11yChar.fire(stringFromCodePoint(code)); + if (screenReaderMode) { + this._onA11yChar.fire(stringFromCodePoint(code)); + } + const linkId = this._getCurrentLinkId(); + if (linkId) { + this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y); + } + overflowing = this._activeBuffer.x + chWidth - oldWidth > cols; + } else { + oldWidth = 0; + chWidth = 0; + code = -1; + shouldJoin = false; } - const linkId = this._getCurrentLinkId(); - if (linkId) { - this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y); + if (pendingStart >= 0 && (code < 0 || overflowing || shouldJoin)) { + // insert mode: move characters to right + if (insertMode) { + // right shift cells according to the width + bufferRow.insertCells(this._activeBuffer.x - pendingCols, pendingCols, this._activeBuffer.getNullCell(curAttr)); + // test last cell - since the last cell has only room for + // a halfwidth char any fullwidth shifted there is lost + // and will be set to empty cell + if (bufferRow.getWidth(cols - 1) === 2) { + bufferRow.setCellFromCodepoint(cols - 1, NULL_CELL_CODE, NULL_CELL_WIDTH, curAttr); + } + } + bufferRow.setCellsFromCodepoints(this._activeBuffer.x - pendingCols, pendingCols, data, pendingStart, pos, curAttr); + pendingStart = -1; + pendingCols = 0; + } + if (code < 0) { + break; } // goto next line if ch would overflow // NOTE: To avoid costly width checks here, // the terminal does not allow a cols < 2. - if (this._activeBuffer.x + chWidth - oldWidth > cols) { + if (overflowing) { // autowrap - DECAWM // automatically wraps to the beginning of the next line if (wraparoundMode) { @@ -595,7 +635,7 @@ export class InputHandler extends Disposable implements IInputHandler { 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); + bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y) as BufferLine if (!bufferRow) { return; } @@ -634,30 +674,11 @@ export class InputHandler extends Disposable implements IInputHandler { continue; } - // insert mode: move characters to right - if (insertMode) { - // right shift cells according to the width - bufferRow.insertCells(this._activeBuffer.x, chWidth - oldWidth, this._activeBuffer.getNullCell(curAttr)); - // test last cell - since the last cell has only room for - // a halfwidth char any fullwidth shifted there is lost - // and will be set to empty cell - if (bufferRow.getWidth(cols - 1) === 2) { - bufferRow.setCellFromCodepoint(cols - 1, NULL_CELL_CODE, NULL_CELL_WIDTH, curAttr); - } - } - - // write current char to buffer and advance cursor - bufferRow.setCellFromCodepoint(this._activeBuffer.x++, code, chWidth, curAttr); - - // fullwidth char - also set next cell to placeholder stub and advance cursor - // for graphemes bigger than fullwidth we can simply loop to zero - // we already made sure above, that this._activeBuffer.x + chWidth will not overflow right - if (chWidth > 0) { - while (--chWidth) { - // other than a regular empty cell a cell following a wide char has no width - bufferRow.setCellFromCodepoint(this._activeBuffer.x++, 0, 0, curAttr); - } - } + data[pos] = code | (chWidth << Content.WIDTH_SHIFT); + if (pendingStart < 0) pendingStart = pos; + pendingCols += chWidth; + // bufferRow.setCellsFromCodepoints(this._activeBuffer.x, chWidth, data, pos, pos+1, curAttr); + this._activeBuffer.x += chWidth; } this._parser.precedingJoinState = precedingJoinState; diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index fd4ed298dc..5a16088bbb 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -49,6 +49,7 @@ export class Buffer extends Disposable implements IBuffer { private _cols: number; private _rows: number; private _isClearing: boolean = false; + constructor( private _hasScrollback: boolean, private _optionsService: IOptionsService, diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 3bf2220f5a..950b218b88 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -206,6 +206,39 @@ export class LogicalLine implements ILogicalLine { this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; } + public setCellsFromCodepoints(index: LogicalColumn, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void { + if (index + cols >= this.length) { + this.resizeData(index + cols); + for (let i = this.length; i < index; i++) { + this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + this._data[i * Constants.CELL_INDICIES + Cell.FG] = 0; + this._data[i * Constants.CELL_INDICIES + Cell.BG] = this.backgroundColor; + } + this.length = index + cols; + } + const data = this._data; + const fg = attrs.fg; + const bg = attrs.bg; + const ext = (attrs.bg & BgFlags.HAS_EXTENDED) ? attrs.extended : undefined; + let j = index * Constants.CELL_INDICIES; + for (let i = start; i < end; i++) { + const contents = codePoints[i]; + let width = (contents >>> Content.WIDTH_SHIFT); + data[j + Cell.CONTENT] = contents; + data[j + Cell.FG] = fg; + data[j + Cell.BG] = bg; + ext && (this._extendedAttrs[index] = ext); + j += 3; index++; + while (--width > 0) { + data[j + Cell.CONTENT] = 0; + data[j + Cell.FG] = fg; + data[j + Cell.BG] = bg; + ext && (this._extendedAttrs[index] = ext); + j += 3; index++; + } + } + } + /** * Cleanup underlying array buffer. * A cleanup will be triggered if the array buffer exceeds the actual used @@ -554,6 +587,10 @@ export class BufferLine implements IBufferLine { this._logicalLine.setCellFromCodepoint(index + this.startColumn, codePoint, width, attrs); } + public setCellsFromCodepoints(index: number, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void { + this._cacheValid = false; + this._logicalLine.setCellsFromCodepoints(index + this.startColumn, cols, codePoints, start, end, attrs); + } /** * Add a codepoint to a cell from input handler. diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index c2d176e103..74d8430d50 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -75,7 +75,7 @@ export class BufferService extends Disposable implements IBufferService { if (isWrapped) { lline = oldLine.logical(); } else { - lline = new LogicalLine(0); + lline = new LogicalLine(this.cols); } const newLine = buffer.getBlankLine(eraseAttr, lline) as BufferLine; if (isWrapped && oldLine) { From 7a3fa48258359cf4c2b2f5f6c9af2eb514f05fcc Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 28 Aug 2026 12:15:32 -0700 Subject: [PATCH 06/43] Allocate sucessive lines in normal buffer from large block. This typically reduces memory usage, allocation, and copying. --- src/common/InputHandler.ts | 9 +- src/common/buffer/Buffer.ts | 22 +++- src/common/buffer/BufferLine.ts | 154 +++++++++++++-------------- src/common/buffer/Types.ts | 4 + src/common/services/BufferService.ts | 17 ++- 5 files changed, 118 insertions(+), 88 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 2a4803a1bf..bc8ae2353d 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -15,6 +15,7 @@ import { BufferLine, DEFAULT_ATTR_DATA } from './buffer/BufferLine'; import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from './parser/Types'; import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from './buffer/Constants'; import { CellData } from './buffer/CellData'; +import { Buffer } from './buffer/Buffer'; import { AttributeData } from './buffer/AttributeData'; import { ICoreService, IBufferService, IOptionsService, ILogService, IMouseStateService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from './services/Services'; import { UnicodeService } from './services/UnicodeService'; @@ -526,8 +527,9 @@ export class InputHandler extends Disposable implements IInputHandler { const cols = this._bufferService.cols; const wraparoundMode = this._coreService.decPrivateModes.wraparound; const insertMode = this._coreService.modes.insertMode; + const buffer = this._activeBuffer as Buffer; const curAttr = this._curAttrData; - let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y) as BufferLine; + let bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y); // Defensive check: bufferRow can be undefined if a resize occurred mid-write due to async // scheduling gaps in WriteBuffer. See https://github.com/xtermjs/xterm.js/issues/5597 @@ -604,7 +606,8 @@ export class InputHandler extends Disposable implements IInputHandler { bufferRow.setCellFromCodepoint(cols - 1, NULL_CELL_CODE, NULL_CELL_WIDTH, curAttr); } } - bufferRow.setCellsFromCodepoints(this._activeBuffer.x - pendingCols, pendingCols, data, pendingStart, pos, curAttr); + const blockSize = buffer.allocateBigBlock(); + bufferRow.setCellsFromCodepoints(this._activeBuffer.x - pendingCols, pendingCols, data, pendingStart, pos, curAttr, blockSize); pendingStart = -1; pendingCols = 0; } @@ -635,7 +638,7 @@ export class InputHandler extends Disposable implements IInputHandler { 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) as BufferLine + bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y); if (!bufferRow) { return; } diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 5a16088bbb..820ab474ca 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -104,7 +104,7 @@ export class Buffer extends Disposable implements IBuffer { */ public getBlankLine( attr: IAttributeData, - logicalLine: LogicalLine = new LogicalLine() + logicalLine: LogicalLine = new LogicalLine(this._cols) ): IBufferLine { logicalLine.backgroundColor = attr.bg & ~0xFC000000; return new BufferLine(this._cols, logicalLine); @@ -114,6 +114,21 @@ export class Buffer extends Disposable implements IBuffer { return this._hasScrollback && this.lines.maxLength > this._rows; } + /** + * Should we allocate a big block to be used for multiple lines? + * This makes sense for the regular buffer, which is "mostly-append". + * Furthermore, a logical line can have hightly-variable length, + * so it makes sense to allocate from the end of a large buffer, and + * for sucessive lines, keep alocating from the same buffer as + * long it has room. + * For the alternate buffer, there is a bounded number of lines, + * and a lot more random-access and back-and-forth. + * @returns 0 if the answer is no, otherwise suggested block size + */ + public allocateBigBlock(): number { + return this.hasScrollback ? 24 * this._cols : 0; + } + public get isCursorInViewport(): boolean { const absoluteY = this.ybase + this.y; const relativeY = absoluteY - this.ydisp; @@ -154,8 +169,11 @@ export class Buffer extends Disposable implements IBuffer { if (this.lines.length === 0) { fillAttr ??= DEFAULT_ATTR_DATA; let i = this._rows; + const normalBuffer = this._hasScrollback; while (i--) { - this.lines.push(this.getBlankLine(fillAttr)); + const line = this.getBlankLine(fillAttr, + normalBuffer ? new LogicalLine(0) : undefined); + this.lines.push(line); } } } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 950b218b88..59fc5c6716 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -22,12 +22,14 @@ const enum Constants { CLEANUP_THRESHOLD = 2 } -/** Column count within current visible BufferLine(row). +/* + * Column count within current visible BufferLine(row). * The left-most column is column 0. */ export type BufferColumn = number; -/** Column count within current LogicalLine. +/* + * 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.) @@ -69,9 +71,12 @@ const EMPTY_DATA = new Uint32Array(0); */ export class LogicalLine implements ILogicalLine { /** + * Available for this line is _data.subarray(_dstaStart, _dataStart + _dataLength) * @internal */ public _data: Uint32Array; + public _dataStart: number = 0; + public _dataLength: number = 0; /** Sparse cache; only read when `IS_COMBINED_MASK` is set in `_data`. */ public _combined: {[index: LogicalColumn]: string} = {}; /** @@ -84,35 +89,43 @@ export class LogicalLine implements ILogicalLine { public backgroundColor: number = 0; /** * Logical "trimmed" length of line. - * Must be no more than this._data.length / 3. + * Must be no more than this._dataLength / 3. */ public length: number = 0; - constructor(cols: number = 0, data = cols === 0 ? EMPTY_DATA : new Uint32Array(cols * Constants.CELL_INDICIES)) { + constructor(cols: number = 0, data: Uint32Array = cols === 0 ? EMPTY_DATA : new Uint32Array(cols * Constants.CELL_INDICIES), start: number = 0, dlength: number = data.length - start) { this._data = data; + this._dataStart = start; + this._dataLength = dlength; } /** * @internal */ - public resizeData(cols: number): void { + public resizeData(cols: number, allocateBigBlock: number = 0): void { const uint32Cells = cols * Constants.CELL_INDICIES; - const oldLength = this._data.length; + const oldLength = this._dataLength; if (uint32Cells >= oldLength) { // increase by at least 50% - const newLength = Math.max(uint32Cells + 60, (3 * oldLength) >> 1); + const newLength = Math.max(uint32Cells + 60, + allocateBigBlock ? allocateBigBlock * 3 : (3 * oldLength) >> 1); const data = new Uint32Array(newLength); - data.set(this._data); + for (let i = 3 * this.length; --i >= 0; ) { + data[i] = this._data[this._dataStart + i]; + } this._data = data; + this._dataStart = 0; + this._dataLength = newLength; } } public getWidth(index: LogicalColumn): number { return index >= this.length ? NULL_CELL_WIDTH - : this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; + : this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; } - /** usually same as argument, but adjust if wide or at end. + /** + * Usually same as argument, but adjust if wide or at end. * @internal */ public charStart(column: LogicalColumn): number { @@ -131,7 +144,7 @@ export class LogicalLine implements ILogicalLine { cell.bg = this.backgroundColor; return cell; } - const startIndex = index * Constants.CELL_INDICIES; + const startIndex = this._dataStart + index * Constants.CELL_INDICIES; cell.content = this._data[startIndex + Cell.CONTENT]; cell.fg = this._data[startIndex + Cell.FG]; cell.bg = this._data[startIndex + Cell.BG]; @@ -147,7 +160,7 @@ export class LogicalLine implements ILogicalLine { // prior loadCell into a reused CellData (e.g. $workCell during insert/delete). cell.extended = DEFAULT_ATTR_DATA.extended.clone(); // We use $extended as blueprint and reset the internals - // mimicking the ctor to avoid a new allocation. + // mimicking the ctor to avoid a new allocation. $extended._ext = 0; $extended._urlId = 0; cell.extended = $extended; @@ -157,7 +170,7 @@ export class LogicalLine implements ILogicalLine { /** Returns the string content of the cell. */ public getString(index: number): string { - const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { return this._combined[index]; } @@ -170,7 +183,7 @@ export class LogicalLine implements ILogicalLine { /** Get state of protected flag. */ public isProtected(index: number): number { - return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; + return this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } /** @@ -191,10 +204,12 @@ export class LogicalLine implements ILogicalLine { } if (index >= this.length) { this.resizeData(index + 1); + let j = this._dataStart + index * Constants.CELL_INDICIES; for (let i = this.length; i < index; i++) { - this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; - this._data[i * Constants.CELL_INDICIES + Cell.FG] = 0; - this._data[i * Constants.CELL_INDICIES + Cell.BG] = this.backgroundColor; + const j = this._dataStart + i * Constants.CELL_INDICIES; + this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + this._data[j + Cell.FG] = 0; + this._data[j + Cell.BG] = this.backgroundColor; } this.length = index + 1; } @@ -206,13 +221,14 @@ export class LogicalLine implements ILogicalLine { this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; } - public setCellsFromCodepoints(index: LogicalColumn, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void { + public setCellsFromCodepoints(index: LogicalColumn, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData, allocateBigBlock: number = 0): void { if (index + cols >= this.length) { - this.resizeData(index + cols); + this.resizeData(index + cols, allocateBigBlock); for (let i = this.length; i < index; i++) { - this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; - this._data[i * Constants.CELL_INDICIES + Cell.FG] = 0; - this._data[i * Constants.CELL_INDICIES + Cell.BG] = this.backgroundColor; + const j = this._dataStart + i * Constants.CELL_INDICIES; + this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + this._data[j + Cell.FG] = 0; + this._data[j + Cell.BG] = this.backgroundColor; } this.length = index + cols; } @@ -220,7 +236,7 @@ export class LogicalLine implements ILogicalLine { const fg = attrs.fg; const bg = attrs.bg; const ext = (attrs.bg & BgFlags.HAS_EXTENDED) ? attrs.extended : undefined; - let j = index * Constants.CELL_INDICIES; + let j = this._dataStart + index * Constants.CELL_INDICIES; for (let i = start; i < end; i++) { const contents = codePoints[i]; let width = (contents >>> Content.WIDTH_SHIFT); @@ -239,39 +255,6 @@ export class LogicalLine implements ILogicalLine { } } - /** - * Cleanup underlying array buffer. - * A cleanup will be triggered if the array buffer exceeds the actual used - * memory by a factor of Constants.CLEANUP_THRESHOLD. - * Returns 0 or 1 indicating whether a cleanup happened. - */ - public cleanupMemory(threshold: number = 1.3): number { - const cols = this.length; - if (cols * Constants.CELL_INDICIES * 4 * threshold < this._data.buffer.byteLength) { - const data = new Uint32Array(Constants.CELL_INDICIES * 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 */ @@ -279,7 +262,8 @@ export class LogicalLine implements ILogicalLine { let index = this.length; while (index > 0) { index--; - const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + const j = this._dataStart + index * Constants.CELL_INDICIES; + const content = this._data[j + Cell.CONTENT]; if (content & Content.HAS_CONTENT_MASK) { index++; break; @@ -335,7 +319,7 @@ export class LogicalLine implements ILogicalLine { const cellContents: string[] = []; while (startCol < endCol) { const content = startCol >= dataLength ? 0 - : this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; + : this._data[this._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; cellContents.push(chars); @@ -423,10 +407,10 @@ export class BufferLine implements IBufferLine { if (lindex >= this.validEnd) { return [0, '', NULL_CELL_WIDTH, 0]; } - const content = lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; return [ - lline._data[lindex * Constants.CELL_INDICIES + Cell.FG], + lline._data[lline._dataStart + lindex * Constants.CELL_INDICIES + Cell.FG], (content & Content.IS_COMBINED_MASK) ? lline._combined[lindex] : (cp) ? stringFromCodePoint(cp) : '', @@ -464,7 +448,7 @@ export class BufferLine implements IBufferLine { public getFg(index: number): number { const lline = this._logicalLine; const lcolumn = index + this.startColumn; - return lcolumn >= this.validEnd ? 0 : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.FG]; + return lcolumn >= this.validEnd ? 0 : lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.FG]; } /** Get BG cell component. */ @@ -472,7 +456,7 @@ export class BufferLine implements IBufferLine { index += this.startColumn; const lline = this._logicalLine; return index > lline.length ? lline.backgroundColor - : lline._data[index * Constants.CELL_INDICIES + Cell.BG]; + : lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.BG]; } /** @@ -486,7 +470,7 @@ export class BufferLine implements IBufferLine { return 0; } const lline = this._logicalLine; - return lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; + return lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** @@ -500,7 +484,7 @@ export class BufferLine implements IBufferLine { if (lcolumn >= this.validEnd) { return 0; } - const content = lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { const combined = lline._combined[lcolumn]; return combined.charCodeAt(combined.length - 1); @@ -515,7 +499,7 @@ export class BufferLine implements IBufferLine { if (lcolumn >= this.validEnd) { return 0; } - return lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; + return lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ @@ -525,7 +509,7 @@ export class BufferLine implements IBufferLine { if (lcolumn >= this.validEnd) { return ''; } - const content = lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { return lline._combined[lcolumn]; } @@ -541,7 +525,7 @@ export class BufferLine implements IBufferLine { 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; + : lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } /** @@ -587,9 +571,9 @@ export class BufferLine implements IBufferLine { this._logicalLine.setCellFromCodepoint(index + this.startColumn, codePoint, width, attrs); } - public setCellsFromCodepoints(index: number, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void { + public setCellsFromCodepoints(index: number, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData, allocateBigBlock: number = 0): void { this._cacheValid = false; - this._logicalLine.setCellsFromCodepoints(index + this.startColumn, cols, codePoints, start, end, attrs); + this._logicalLine.setCellsFromCodepoints(index + this.startColumn, cols, codePoints, start, end, attrs, allocateBigBlock); } /** @@ -608,7 +592,7 @@ export class BufferLine implements IBufferLine { this.setCellFromCodepoint(index, codePoint, 1, CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE])); return; } - let content = lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; + let content = lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add lline._combined[lcolumn] += stringFromCodePoint(codePoint); @@ -630,7 +614,7 @@ export class BufferLine implements IBufferLine { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; } - lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] = content; + lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] = content; } public insertCells(pos: number, n: number, fillCellData: ICellData): void { @@ -737,17 +721,20 @@ export class BufferLine implements IBufferLine { throw new Error('invalid call to resize'); } if (cols === this.length) { - return logical._data.length * 4 * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; + return logical._dataLength * 4 * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } const uint32Cells = cols * Constants.CELL_INDICIES; if (cols > this.length) { logical.resizeData(cols); + logical.length = cols; for (let i = this.length; i < cols; ++i) { this.setCell(i, fillCellData); } } else { // optimization: just shrink the view on existing buffer logical._data = logical._data.subarray(0, cols * Constants.CELL_INDICIES); + logical._dataStart = 0; + logical._dataLength = cols * Constants.CELL_INDICIES; // Remove any cut off combined data const keys = Object.keys(logical._combined); for (let i = 0; i < keys.length; i++) { @@ -774,9 +761,10 @@ export class BufferLine implements IBufferLine { * A cleanup will be triggered if the array buffer exceeds the actual used * memory by a factor of Constants.Constants.CLEANUP_THRESHOLD. * Returns 0 or 1 indicating whether a cleanup happened. + * @deprecated */ public cleanupMemory(): number { - return this._logicalLine.cleanupMemory(Constants.CLEANUP_THRESHOLD); + return 0; } /** fill a line with fillCharData */ @@ -814,9 +802,10 @@ export class BufferLine implements IBufferLine { const startColumn = this.startColumn; const data = logicalLine._data; for (let i = this.validEnd; --i >= startColumn; ) { - if ((data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) - || (noBg && (data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK))) { - i += data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; + const j = logicalLine._dataStart + i * Constants.CELL_INDICIES; + if ((data[j + Cell.CONTENT] & Content.HAS_CONTENT_MASK) + || (noBg && (data[j + Cell.BG] & Attributes.CM_MASK))) { + i += data[j + Cell.CONTENT] >> Content.WIDTH_SHIFT; return i - startColumn; } } @@ -852,8 +841,11 @@ export class BufferLine implements IBufferLine { public eraseRight(index: BufferColumn): void { this._cacheValid = false; const lineStart = this.startColumn; - const lineEnd = lineStart + index; + let lineEnd = lineStart + index; const lline = this._logicalLine; + if (lineEnd > lineStart && lline.getWidth(lineEnd - 1) === 2) { + lineEnd--; + } if (this.nextBufferLine) { const oldEnd = this.nextBufferLine.startColumn; const count = oldEnd - lineEnd; @@ -881,10 +873,12 @@ export class BufferLine implements IBufferLine { const oldLogical = this._logicalLine; logicalLine.resizeData(column + oldLogical.length); const newData = logicalLine._data; + const dataStart = logicalLine._dataStart; for (let i = logicalLine.length; i < column + oldLogical.length; i++) { - newData[i * Constants.CELL_INDICIES + Cell.CONTENT] = 0; - newData[i * Constants.CELL_INDICIES + Cell.FG] = 0; - newData[i * Constants.CELL_INDICIES + Cell.BG] = logicalLine.backgroundColor; + const i3 = dataStart + i * Constants.CELL_INDICIES; + newData[i3 + Cell.CONTENT] = 0; + newData[i3 + Cell.FG] = 0; + newData[i3 + Cell.BG] = logicalLine.backgroundColor; } logicalLine.copyCellsFrom(oldLogical, 0, column, oldLogical.length, false); /* diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index c48c26283b..81301de1b2 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -127,11 +127,15 @@ export interface IBufferLine { loadCell(index: number, cell: ICellData): ICellData; setCell(index: number, cell: ICellData): void; setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void; + setCellsFromCodepoints(index: number, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void; addCodepointToCell(index: number, codePoint: number, width: number): void; insertCells(pos: number, n: number, ch: ICellData): void; deleteCells(pos: number, n: number, fill: ICellData): void; replaceCells(start: number, end: number, fill: ICellData, respectProtect?: boolean): void; resize(cols: number, fill: ICellData): boolean; + /** + * @deprecated + */ cleanupMemory(): number; fill(fillCellData: ICellData, respectProtect?: boolean): void; copyFrom(line: IBufferLine): void; diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 74d8430d50..a2163fcc9e 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -6,6 +6,7 @@ import { Disposable } from '../Lifecycle'; import { IAttributeData, IBuffer, IBufferLine, IBufferSet } from '../buffer/Types'; import { BufferSet } from '../buffer/BufferSet'; +import { Buffer } from '../buffer/Buffer'; import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from './Services'; import { Emitter } from '../Event'; import { BufferLine, LogicalLine } from '../buffer/BufferLine'; @@ -67,13 +68,23 @@ export class BufferService extends Disposable implements IBufferService { * @param isWrapped Whether the new line is wrapped from the previous line. */ public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void { - const buffer = this.buffer; + const buffer = this.buffer as Buffer; const topRow = buffer.ybase + buffer.scrollTop; const bottomRow = buffer.ybase + buffer.scrollBottom; + if (bottomRow !== buffer.lines.length - 1) { + buffer.setWrapped(bottomRow + 1, false); + } const oldLine = buffer.lines.get(bottomRow) as BufferLine; - let lline: LogicalLine; + let lline: LogicalLine = oldLine.logical(); + const dbuffer = lline._data; if (isWrapped) { - lline = oldLine.logical(); + } else if (buffer.allocateBigBlock() > 0) { + // In this case we try to use a large block for many lines. + // Grab the rest of the block + const newStart = lline._dataStart + 3 * lline.length; + const newLength = lline._dataLength - 3 * lline.length; + lline._dataLength = lline.length; + lline = new LogicalLine(0, dbuffer, newStart, newLength); } else { lline = new LogicalLine(this.cols); } From fbc57f68bf2f86f8643462c90f0df6e751e3640f Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 29 Aug 2026 10:29:10 -0700 Subject: [PATCH 07/43] Minor optimization - don't check for url links on each character Also restored CircularList.recycle, pending possible future use - though the initial attempt actually slowed things down. --- src/common/CircularList.ts | 15 +++++++++++++++ src/common/InputHandler.test.ts | 3 ++- src/common/InputHandler.ts | 9 +++++---- src/common/buffer/BufferLine.ts | 11 +++++++++++ src/common/services/BufferService.ts | 5 +++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index d47bf2678b..b773391f96 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -31,6 +31,7 @@ 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; @@ -135,6 +136,20 @@ 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 575fb93bb5..8a5ce3994a 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -483,7 +483,8 @@ describe('InputHandler', () => { 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(3)!.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 bc8ae2353d..aa41828ae2 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -545,6 +545,7 @@ export class InputHandler extends Disposable implements IInputHandler { } let precedingJoinState = this._parser.precedingJoinState; + const linkId = this._getCurrentLinkId(); let pendingStart = -1; let pendingCols = 0; for (let pos = start; ; ++pos) { @@ -583,10 +584,6 @@ export class InputHandler extends Disposable implements IInputHandler { if (screenReaderMode) { this._onA11yChar.fire(stringFromCodePoint(code)); } - const linkId = this._getCurrentLinkId(); - if (linkId) { - this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y); - } overflowing = this._activeBuffer.x + chWidth - oldWidth > cols; } else { oldWidth = 0; @@ -595,6 +592,10 @@ export class InputHandler extends Disposable implements IInputHandler { shouldJoin = false; } if (pendingStart >= 0 && (code < 0 || overflowing || shouldJoin)) { + if (linkId) { + this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y); + } + // insert mode: move characters to right if (insertMode) { // right shift cells according to the width diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 59fc5c6716..3426226476 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -98,6 +98,11 @@ export class LogicalLine implements ILogicalLine { this._dataStart = start; this._dataLength = dlength; } + public setData(data: Uint32Array, start: number, dlength: number): void { + this._data = data; + this._dataStart = start; + this._dataLength = dlength; + } /** * @internal @@ -392,6 +397,12 @@ export class BufferLine implements IBufferLine { this.length = cols; logicalLine.firstBufferLine ??= this; } + public reinit(cols: number, + logicalLine: LogicalLine): void { + this._logicalLine = logicalLine; + this.length = cols; + logicalLine.firstBufferLine ??= this; + } public get isWrapped(): boolean { return this._logicalLine.firstBufferLine !== this; diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index a2163fcc9e..df7619f42f 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -88,6 +88,11 @@ export class BufferService extends Disposable implements IBufferService { } else { lline = new LogicalLine(this.cols); } + + // A possible optimizaton when willBufferBeTrimmed is to use + // CircularList.recycle instead of creating new BufferLine/LogicalLine + // instances - but it seems to be slightly slower as well as + // more complicated. May be worth investigating why it is slower. const newLine = buffer.getBlankLine(eraseAttr, lline) as BufferLine; if (isWrapped && oldLine) { oldLine.nextBufferLine = newLine; From 3ce563c9cee145ac2c857c393a2dc6c257f50a91 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 29 Aug 2026 12:00:20 -0700 Subject: [PATCH 08/43] Use recycled lines from CircularList trimming Seems slightly faster to recycle BufferLine but not LogicalLine. --- src/common/buffer/BufferLine.ts | 3 +++ src/common/services/BufferService.ts | 32 +++++++++++++++++----------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 3426226476..bd977b9005 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -402,6 +402,9 @@ export class BufferLine implements IBufferLine { this._logicalLine = logicalLine; this.length = cols; logicalLine.firstBufferLine ??= this; + this._cacheValid = false; + this._cache = ''; + this.startColumn = 0; } public get isWrapped(): boolean { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index df7619f42f..2e9f32d16c 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -71,12 +71,18 @@ export class BufferService extends Disposable implements IBufferService { const buffer = this.buffer as Buffer; const topRow = buffer.ybase + buffer.scrollTop; const bottomRow = buffer.ybase + buffer.scrollBottom; - if (bottomRow !== buffer.lines.length - 1) { + const atBottom = bottomRow === buffer.lines.length - 1; + if (!atBottom) { buffer.setWrapped(bottomRow + 1, false); } const oldLine = buffer.lines.get(bottomRow) as BufferLine; + // Determine whether the buffer is going to be trimmed after insertion. + const willBufferBeTrimmed = buffer.lines.isFull; + let lline: LogicalLine = oldLine.logical(); const dbuffer = lline._data; + const recycledLine = buffer.scrollTop === 0 && atBottom && willBufferBeTrimmed + && buffer.lines.recycle() as BufferLine; if (isWrapped) { } else if (buffer.allocateBigBlock() > 0) { // In this case we try to use a large block for many lines. @@ -84,16 +90,19 @@ export class BufferService extends Disposable implements IBufferService { const newStart = lline._dataStart + 3 * lline.length; const newLength = lline._dataLength - 3 * lline.length; lline._dataLength = lline.length; + // Perhaps should also re-use recycledLine.logical(). + // However, that seems to be slightly slower - unclear why. lline = new LogicalLine(0, dbuffer, newStart, newLength); } else { lline = new LogicalLine(this.cols); } - - // A possible optimizaton when willBufferBeTrimmed is to use - // CircularList.recycle instead of creating new BufferLine/LogicalLine - // instances - but it seems to be slightly slower as well as - // more complicated. May be worth investigating why it is slower. - const newLine = buffer.getBlankLine(eraseAttr, lline) as BufferLine; + let newLine; + if (recycledLine) { + recycledLine.reinit(this.cols, lline); + newLine = recycledLine; + } else { + newLine = buffer.getBlankLine(eraseAttr, lline) as BufferLine; + } if (isWrapped && oldLine) { oldLine.nextBufferLine = newLine; newLine.startColumn = lline.length; @@ -101,12 +110,11 @@ export class BufferService extends Disposable implements IBufferService { lline.backgroundColor = eraseAttr.bg; if (buffer.scrollTop === 0) { - // Determine whether the buffer is going to be trimmed after insertion. - const willBufferBeTrimmed = buffer.lines.isFull; - // Insert the line using the fastest method - if (bottomRow === buffer.lines.length - 1) { - buffer.lines.push(newLine); + if (atBottom) { + if (!willBufferBeTrimmed) { + buffer.lines.push(newLine); + } } else { buffer.lines.splice(bottomRow + 1, 0, newLine); } From 1ccc47e2d5a6ce2896bb99fd9f28129ef0266f44 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 1 Sep 2026 10:00:35 -0700 Subject: [PATCH 09/43] Use new COLOR_MASK constant when setting LogicalLine's backgroundColor. --- src/common/InputHandler.ts | 2 +- src/common/buffer/Buffer.ts | 4 ++-- src/common/buffer/Constants.ts | 5 +++++ src/common/services/BufferService.ts | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index aa41828ae2..332251a5d7 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1206,7 +1206,7 @@ export class InputHandler extends Disposable implements IInputHandler { const next = line.nextBufferLine; if (next) next.asUnwrapped(line); line.eraseRight(start); - line.logical().backgroundColor = this._curAttrData.bg & ~0xFC000000; + line.logical().backgroundColor = this._curAttrData.bg & Attributes.COLOR_MASK; } else { line.replaceCells( start, diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 820ab474ca..c68561eb71 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -11,7 +11,7 @@ import { ExtendedAttrs } from './AttributeData'; import { BufferLine, LogicalLine, DEFAULT_ATTR_DATA } from './BufferLine'; import { reflowLine, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove } from './BufferReflow'; import { CellData } from './CellData'; -import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from './Constants'; +import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH, Attributes } from './Constants'; import { Marker } from './Marker'; import { DEFAULT_CHARSET } from '../data/Charsets'; import { IBufferService, ILogService, IOptionsService } from '../services/Services'; @@ -106,7 +106,7 @@ export class Buffer extends Disposable implements IBuffer { attr: IAttributeData, logicalLine: LogicalLine = new LogicalLine(this._cols) ): IBufferLine { - logicalLine.backgroundColor = attr.bg & ~0xFC000000; + logicalLine.backgroundColor = attr.bg & Attributes.COLOR_MASK; return new BufferLine(this._cols, logicalLine); } diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index b5a6a4d4fa..69403304ef 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -75,6 +75,11 @@ export const enum Content { } export const enum Attributes { + /** + * Color is encoded using 26 bits. The high-order 2 bits is the + * color mode, which specifies how to interpret the remaining 24 bits. + */ + COLOR_MASK = 0x3FFFFFF, /* CM_MASK | RGB_MASK */ /** * bit 1..8 blue in RGB, color in P256 and P16 */ diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 2e9f32d16c..33e49370dd 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -7,6 +7,7 @@ import { Disposable } from '../Lifecycle'; import { IAttributeData, IBuffer, IBufferLine, IBufferSet } from '../buffer/Types'; import { BufferSet } from '../buffer/BufferSet'; import { Buffer } from '../buffer/Buffer'; +import { Attributes } from '../buffer/Constants'; import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from './Services'; import { Emitter } from '../Event'; import { BufferLine, LogicalLine } from '../buffer/BufferLine'; @@ -107,7 +108,7 @@ export class BufferService extends Disposable implements IBufferService { oldLine.nextBufferLine = newLine; newLine.startColumn = lline.length; } - lline.backgroundColor = eraseAttr.bg; + lline.backgroundColor = eraseAttr.bg & Attributes.COLOR_MASK; if (buffer.scrollTop === 0) { // Insert the line using the fastest method From a3be9ce6d85eee154f8c05238af5a5a862ae825b Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 1 Sep 2026 10:06:47 -0700 Subject: [PATCH 10/43] Fix missing addition of LogicalLine._dataStart in setCellFromCodepoint. --- src/common/buffer/BufferLine.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index fb1b589c73..5117fdfee0 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -228,9 +228,10 @@ export class LogicalLine implements ILogicalLine { if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); - this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg; - this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; + const j = this._dataStart + index * Constants.CELL_INDICIES; + this._data[j + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); + this._data[j + Cell.FG] = attrs.fg; + this._data[j + Cell.BG] = attrs.bg; } public setCellsFromCodepoints(index: LogicalColumn, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData, allocateBigBlock: number = 0): void { From b2a36f5f0941cd228924d81cafa80ed4dde7c810 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 1 Sep 2026 10:31:40 -0700 Subject: [PATCH 11/43] Change getExtended to return undefined when there is no ExtendedAttrs I think this is slightly cleaner and possibly faster, since no cloning needed. --- src/common/buffer/BufferLine.ts | 34 ++++++++++++++++++--------------- src/common/buffer/Types.ts | 2 +- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 5117fdfee0..ef23730014 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -161,23 +161,27 @@ export class LogicalLine implements ILogicalLine { } else { cell.combinedData = ''; } - cell.extended = this.getExtended(index); + if (cell.bg & BgFlags.HAS_EXTENDED) { + cell.extended = this._extendedAttrs[index]!; + } else { + // Do not mutate cell.extended in place: it may still reference this line's map entry from a + // prior loadCell into a reused CellData (e.g. $workCell during insert/dele + // We use $extended as blueprint and reset the internals + // mimicking the ctor to avoid a new allocation. + $extended._ext = 0; + $extended._urlId = 0; + $extended.payload = undefined; + cell.extended = $extended; + } + return cell; } - public getExtended(index: LogicalColumn, validEnd: LogicalColumn = this.length): IExtendedAttrs { - const startIndex = this._dataStart + index * Constants.CELL_INDICIES; - if (this._data[startIndex + Cell.BG] & BgFlags.HAS_EXTENDED) { - return this._extendedAttrs[index]!; - } - // Do not mutate cell.extended in place: it may still reference this line's map entry from a - // prior loadCell into a reused CellData (e.g. $workCell during insert/delete). - // We use $extended as blueprint and reset the internals - // mimicking the ctor to avoid a new allocation. - $extended._ext = 0; - $extended._urlId = 0; - $extended.payload = undefined; - return $extended; + public getExtended(index: LogicalColumn, validEnd: LogicalColumn = this.length): IExtendedAttrs | undefined { + return index < this.length + && (this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) + ? this._extendedAttrs[index] + : undefined; } /** Returns the string content of the cell. */ @@ -571,7 +575,7 @@ export class BufferLine implements IBufferLine { return lline.loadCell(lcolumn, cell); } - public getExtended(index: number): IExtendedAttrs { + public getExtended(index: number): IExtendedAttrs | undefined { const lline = this._logicalLine; const lcolumn = index + this.startColumn; return lline.getExtended(lcolumn, this.validEnd); diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 36873a96e0..70fac1c1ce 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -153,7 +153,7 @@ export interface IBufferLine { getCodePoint(index: number): number; isCombined(index: number): number; getString(index: number): string; - getExtended(index: number): IExtendedAttrs; + getExtended(index: number): IExtendedAttrs | undefined; } export interface IMarker extends IDisposable { From e53f2d30bd0f1b8f723e9b61843517cdb6e792de Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 1 Sep 2026 11:15:37 -0700 Subject: [PATCH 12/43] Fix issue in setCellFromCodepoint reported by lint --- src/common/buffer/BufferLine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index ef23730014..b5da06dd3f 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -220,12 +220,12 @@ export class LogicalLine implements ILogicalLine { } if (index >= this.length) { this.resizeData(index + 1); - let j = this._dataStart + index * Constants.CELL_INDICIES; + let j = this._dataStart + this.length * Constants.CELL_INDICIES; for (let i = this.length; i < index; i++) { - const j = this._dataStart + i * Constants.CELL_INDICIES; this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; this._data[j + Cell.FG] = 0; this._data[j + Cell.BG] = this.backgroundColor; + j += Constants.CELL_INDICIES; } this.length = index + 1; } From 6ce5d6b5bf5abdfe43ecaf6b7aa6ada0e19306c8 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 1 Sep 2026 12:38:30 -0700 Subject: [PATCH 13/43] Fixes to integration tests. --- addons/addon-image/test/ImageAddon.test.ts | 4 ++-- .../test/SerializeAddon.test.ts | 22 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index f4e54d7f2e..5153b3ab22 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -117,8 +117,8 @@ test.describe('ImageAddon', () => { ); } // 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); + strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._logicalLine._data instanceof Uint32Array'), true); + strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._logicalLine._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'); diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index c8dba3039b..ce84b4fcd7 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.length * 3); + } + return value; + })); + } } return { x: buffer.cursorX, From 529f34c0c20d0b674b32e7f40b4f5b33dde3a9b3 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 7 Sep 2026 11:30:17 -0700 Subject: [PATCH 14/43] Fix a bunch of testsuite failures. (Still a good chunk to go.) --- src/common/buffer/BufferLine.ts | 90 ++++++++++++++++++++++----------- src/common/buffer/CellData.ts | 39 ++++++++++---- src/common/buffer/Constants.ts | 11 ++-- 3 files changed, 97 insertions(+), 43 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 16efdc5747..8e598a5497 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -160,17 +160,21 @@ export class LogicalLine implements ILogicalLine { return cell; } const startIndex = this._dataStart + index * Constants.CELL_INDICIES; - const content = this._data[startIndex + Cell.CONTENT]; - cell.content = content; - cell.fg = this._data[startIndex + Cell.FG]; - cell.bg = this._data[startIndex + Cell.BG]; - if (cell.content & Content.IS_COMBINED_MASK) { + let content = this._data[startIndex + Cell.CONTENT]; + if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; (cell as CellData)._setChars(this._chars, start, start + length); + if (!(content & Content.IS_COMBINED_MASK)) { + content &= ~(Content.STORED_IN_CHARS_MASK|Content.CODEPOINT_MASK); + content |= (length > 0 && this._chars.codePointAt(start)) || 0; + } } else { - (cell as CellData)._setChars('', 0, 0); + (cell as CellData)._setChars('', -1, -1); } + cell.content = content; + cell.fg = this._data[startIndex + Cell.FG]; + cell.bg = this._data[startIndex + Cell.BG]; if (cell.bg & BgFlags.HAS_EXTENDED) { cell.extended = this._extendedAttrs[index]!; } else { @@ -248,7 +252,7 @@ export class LogicalLine implements ILogicalLine { const start = this._chars.length; const length = str.length; this._chars += str; - cell.content = LogicalLine._withRange(cell.content, start, length); + this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = LogicalLine._withRange(cell.content, start, length); } } @@ -261,15 +265,28 @@ export class LogicalLine implements ILogicalLine { */ public setCellFromCodepoint(index: LogicalColumn, codePoint: number, width: number, attrs: IAttributeData): void { this._charsIsTextValue = false; - if (codePoint === 0 && width === 1 && index >= this.length - 1 && attrs.fg === 0 && attrs.bg === this.backgroundColor) { + const isNull = codePoint === 0 && width === 1 + && attrs.bg === this.backgroundColor; + if (isNull && index >= this.length - 1 ) { if (index === this.length - 1) { - // FIXME should also truncate extendedAttrs and composedData + // FIXME should also truncate extendedAttrs + // remove any now-trailing nulls + while (index > 0) { + const j = this._dataStart + (index - 1) * Constants.CELL_INDICIES; + const content = this._data[j + Cell.CONTENT]; + if ((content & Content.HAS_CONTENT_MASK) + || (content >>> Content.WIDTH_MASK) !== 0 + || this._data[j + Cell.BG] !== this.backgroundColor) { + break; + } + index--; + } this.length = index; // this.length - 1; this.trimLength(); } return; } - if (index >= this.length) { + if (index >= this.length && !isNull) { this.resizeData(index + 1); let j = this._dataStart + this.length * Constants.CELL_INDICIES; for (let i = this.length; i < index; i++) { @@ -458,10 +475,15 @@ export class LogicalLine implements ILogicalLine { let nchars = 0; let j = this._dataStart + Cell.CONTENT; for (let i = 0; i < llen; i++) { - const cstr = this.getString(i); - const clen = cstr.length; + let cstr = this.getString(i); + let clen = cstr.length; + const content = this._data[j]; + this._data[j] = LogicalLine._withRange(content, nchars, clen); + if (clen === 0 && (content >> Content.WIDTH_SHIFT) > 0) { + cstr = ' '; + clen = 1; + } cellContents.push(cstr); - this._data[j] = LogicalLine._withRange(this._data[j], nchars, clen); j += Constants.CELL_INDICIES; nchars += clen; } @@ -475,7 +497,10 @@ export class LogicalLine implements ILogicalLine { const start = (startContent & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const lastContent = this._data[this._dataStart + (endCol - 1) * Constants.CELL_INDICIES + Cell.CONTENT]; const lastStart = (lastContent & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; - const lastLength = (lastContent & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + let lastLength = (lastContent & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + if (lastLength === 0 && (lastContent >>> Content.WIDTH_SHIFT) > 0) { + lastLength++; // last char is a NULL + } return this._chars.substring(start, lastStart + lastLength); } return this._chars; @@ -733,7 +758,6 @@ export class BufferLine implements IBufferLine { if (pos && this.getWidth(pos - 1) === 2) { this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); } - if (n < this.length - pos) { for (let i = this.length - pos - n - 1; i >= 0; --i) { this.setCell(pos + n + i, this.loadCell(pos + i, $workCell)); @@ -1042,12 +1066,6 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { - /* - const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - if (isCanonical && this._cacheValid && trimRight === this._cacheTrimmed) { - return this._cache; - } - */ startCol = startCol ?? 0; endCol = endCol ?? this.length; if (trimRight) { @@ -1057,15 +1075,29 @@ export class BufferLine implements IBufferLine { const lineStart = this.startColumn; startCol += lineStart; endCol += lineStart; - const validEnd = this.validEnd; - const paddingNeeded = trimRight || endCol <= validEnd ? 0 - : endCol - validEnd; - let result = lline.getTrimmedString(startCol, endCol); - if (outColumns) { - //FIXME + const validEnd = Math.min(endCol, this.validEnd); + let result = lline.getTrimmedString(startCol, validEnd); + const paddingNeeded = trimRight ? 0 : Math.max(0, endCol - validEnd); + if (paddingNeeded) { + result += WHITESPACE_CELL_CHAR.repeat(paddingNeeded); } - if (!trimRight && endCol > validEnd) { - result += WHITESPACE_CELL_CHAR.repeat(endCol - validEnd); + if (outColumns) { + outColumns.length = 0; + while (startCol < validEnd) { + const content = lline._data[lline._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; + let length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + const width = content >> Content.WIDTH_SHIFT; + if (length === 0) { length = width; } + for (let i = 0; i < length; ++i) { + outColumns.push(startCol - lineStart); + } + startCol += width || 1; // always advance by at least 1 + } + while (startCol < endCol) { + outColumns.push(startCol - lineStart); + startCol++; + } + outColumns.push(startCol); } return result; } diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 98639f5327..37f25fd1e0 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -21,9 +21,20 @@ export class CellData extends AttributeData implements ICellData { } /** Primitives from terminal buffer. */ public content = 0; + /* + public get content(): number { return this._content;} + public set content(v: number) { + this._content = v; } + */ public fg = 0; public bg = 0; public extended: IExtendedAttrs = new ExtendedAttrs(); + /** + * Cache for getChars. + * If _stringStart == -1: Use codepoint in this.content. + * If _stringStart == 0 && _stringEnd == -1: Entire _string. + * Otherwise: substring of _string. + */ public _string: string = ''; public _stringStart: number = 0; public _stringEnd: number = -1; // Whole string @@ -40,16 +51,20 @@ export class CellData extends AttributeData implements ICellData { } /** JS string of the content. */ public getChars(): string { - if (this.content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK)) { - let str = this._string; - if (this._stringEnd >= 0) { - str = str.substring(this._stringStart, this._stringEnd); - this._string = str; - this._stringEnd = -1; - } - return str; + if (this._stringStart === 0 && this._stringEnd < 0) { + return this._string; + } + let str; + if (this._stringStart < 0) { + const codePoint = this.content & Content.CODEPOINT_MASK; + str = codePoint ? stringFromCodePoint(codePoint) : ''; + } else { + str = this._string.substring(this._stringStart, this._stringEnd); } - return ''; + this._string = str; + this._stringStart = 0; + this._stringEnd = -1; + return str; } public _setChars(str: string, strStart: number = 0, strEnd: number = -1): void { this._string = str; @@ -98,9 +113,13 @@ export class CellData extends AttributeData implements ICellData { } if (combined) { this._string = value[CHAR_DATA_CHAR_INDEX]; - this._stringStart = -1; + this._stringStart = 0; this._stringEnd = -1; this.content = Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); + } else { + this._string = ''; + this._stringStart = -1; + this._stringEnd = 0; } } /** Get data as CharData. diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index 300604558d..2b6fcf875a 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -79,13 +79,16 @@ export const enum Content { * shortcut if precondition `0 <= width <= 3` is met: * `content |= width << Content.WIDTH_SHIFT;` */ - WIDTH_MASK = 0xC00000, // 3 << 22 - WIDTH_SHIFT = 22, + WIDTH_MASK = 0xF0000000, // 3 << WIDTH_SHIFT + WIDTH_SHIFT = 28, START_IN_CHARS_MASK = 0x1FFFFF, START_IN_CHARS_SHIFT = 0, - LENGTH_IN_CHARS_MASK = 0xFF000000, + LENGTH_IN_CHARS_MASK = 0x0F000000, LENGTH_IN_CHARS_SHIFT = 24, - STORED_IN_CHARS_MASK = LENGTH_IN_CHARS_MASK + STORED_IN_CHARS_MASK = 0x400000 // 1 << 24 + // FIXME non-optimal values for some shifts and masks. + // The width field must be high-order, unless WIDTH_MASK is used more + // The width first field be high-order bits unless we replace >> by >>>. } export const enum Attributes { From de8c6551509cac556b5ac4f59053f91e28fd4358 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 21 Sep 2026 18:04:19 -0700 Subject: [PATCH 15/43] Fix some testsuite failures. --- src/common/buffer/BufferLine.ts | 67 ++++++++++++++++++++++++++------- src/common/buffer/CellData.ts | 5 --- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 8e598a5497..1e32fe98f2 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -134,6 +134,18 @@ export class LogicalLine implements ILogicalLine { } } + public showContents(): string { + let result = ''; + for (let i = 0; i < this.length; i++) { + if (i > 0) { result += '; '; } + result += `#${i}:`; + result += this._data[i * Constants.CELL_INDICIES + this._dataStart].toString(16); + const s = this.getString(i); + if (s.length > 0) { result += `=${JSON.stringify(s)}`} + } + return result; + } + public getWidth(index: LogicalColumn): number { return index >= this.length ? NULL_CELL_WIDTH : this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; @@ -146,7 +158,7 @@ export class LogicalLine implements ILogicalLine { public charStart(column: LogicalColumn): number { return column > this.length ? this.length : column > 0 && this.getWidth(column - 1) > 1 ? column - 1 - : column; + : column; } /** @@ -203,7 +215,7 @@ export class LogicalLine implements ILogicalLine { if (index >= this.length) { return ''; } - const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; @@ -217,12 +229,12 @@ export class LogicalLine implements ILogicalLine { } /** - * Get codepoint of the cell. - * 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: BufferColumn): number { - const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + * Get codepoint of the cell. + * 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: LogicalColumn): number { + const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; @@ -233,6 +245,21 @@ export class LogicalLine implements ILogicalLine { return content & Content.CODEPOINT_MASK; } + /** + * A null character diplays like space, but is trimmable. + * Specifically it has a zero codepoint, but a width of 1. + */ + public isNullChar(index: LogicalColumn): boolean { + if (index >= this.length) { + return true; + } + const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + return (content & Content.WIDTH_MASK) > 0 + && ((content & Content.STORED_IN_CHARS_MASK) + ? (content & Content.LENGTH_IN_CHARS_MASK) === 0 + : (content & Content.CODEPOINT_MASK) === 0); + } + /** Get state of protected flag. */ public isProtected(index: number): number { return this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; @@ -260,7 +287,6 @@ export class LogicalLine implements ILogicalLine { * Set cell data from input handler. * Since the input handler see the incoming chars as UTF32 codepoints, * it gets an optimized access method. - * Warning - does not invalidatw the string cache - callers should do so. * @internal */ public setCellFromCodepoint(index: LogicalColumn, codePoint: number, width: number, attrs: IAttributeData): void { @@ -275,7 +301,7 @@ export class LogicalLine implements ILogicalLine { const j = this._dataStart + (index - 1) * Constants.CELL_INDICIES; const content = this._data[j + Cell.CONTENT]; if ((content & Content.HAS_CONTENT_MASK) - || (content >>> Content.WIDTH_MASK) !== 0 + || (content & Content.WIDTH_MASK) !== 0 || this._data[j + Cell.BG] !== this.backgroundColor) { break; } @@ -318,6 +344,7 @@ export class LogicalLine implements ILogicalLine { } this.length = index + cols; } + let oldContent = -1; const data = this._data; const fg = attrs.fg; const bg = attrs.bg; @@ -326,6 +353,7 @@ export class LogicalLine implements ILogicalLine { for (let i = start; i < end; i++) { const contents = codePoints[i]; let width = (contents >>> Content.WIDTH_SHIFT); + oldContent = data[j + Cell.CONTENT]; data[j + Cell.CONTENT] = contents; data[j + Cell.FG] = fg; data[j + Cell.BG] = bg; @@ -339,6 +367,9 @@ export class LogicalLine implements ILogicalLine { j += 3; index++; } } + if ((oldContent >>> Content.WIDTH_SHIFT) > 1 && index < this.length) { + data[j + Cell.CONTENT] = NULL_CELL_CODE | (NULL_CELL_WIDTH << Content.WIDTH_SHIFT); + } } /** @@ -671,7 +702,7 @@ export class BufferLine implements IBufferLine { public getString(index: number): string { const lline = this._logicalLine; const lcolumn: LogicalColumn = index + this.startColumn; - if (lcolumn >= this.validEnd) { + if (lcolumn >= this.validEnd) { return ''; } return lline.getString(lcolumn); @@ -757,6 +788,7 @@ export class BufferLine implements IBufferLine { // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char if (pos && this.getWidth(pos - 1) === 2) { this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); + this.setCellFromCodepoint(pos, 0, 1, fillCellData); } if (n < this.length - pos) { for (let i = this.length - pos - n - 1; i >= 0; --i) { @@ -923,7 +955,7 @@ export class BufferLine implements IBufferLine { const data = logicalLine._data; for (let i = this.validEnd; --i >= startColumn; ) { const j = logicalLine._dataStart + i * Constants.CELL_INDICIES; - if ((data[j + Cell.CONTENT] & Content.HAS_CONTENT_MASK) + if (!logicalLine.isNullChar(i) || (noBg && (data[j + Cell.BG] & Attributes.CM_MASK))) { i += data[j + Cell.CONTENT] >> Content.WIDTH_SHIFT; return i - startColumn; @@ -1075,7 +1107,16 @@ export class BufferLine implements IBufferLine { const lineStart = this.startColumn; startCol += lineStart; endCol += lineStart; - const validEnd = Math.min(endCol, this.validEnd); + let validEnd; + if (this.nextBufferLine) { + validEnd = this.nextBufferLine.startColumn; + while (validEnd > startCol && lline.isNullChar(validEnd - 1)) { + validEnd--; + } + } else { + validEnd = this._logicalLine.length; + } + validEnd = Math.min(endCol, validEnd); let result = lline.getTrimmedString(startCol, validEnd); const paddingNeeded = trimRight ? 0 : Math.max(0, endCol - validEnd); if (paddingNeeded) { diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 37f25fd1e0..92f3cfb639 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -21,11 +21,6 @@ export class CellData extends AttributeData implements ICellData { } /** Primitives from terminal buffer. */ public content = 0; - /* - public get content(): number { return this._content;} - public set content(v: number) { - this._content = v; } - */ public fg = 0; public bg = 0; public extended: IExtendedAttrs = new ExtendedAttrs(); From 1ed1c03b2d94a65e8e934cb50ec36397c1a4790f Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 22 Sep 2026 14:20:48 -0700 Subject: [PATCH 16/43] Change CellData handling of well without codepoint. The CellData_string field is now only valid if STORED_IN_CHARS_MASK is set. We don't cache the result of getChars, since then we would need to invalidate the cache, which would require more code changes. --- src/common/buffer/BufferLine.ts | 52 ++++++++++++----------- src/common/buffer/CellData.ts | 73 ++++++++++++++++++--------------- 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 1e32fe98f2..68bb5c8dad 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -5,7 +5,7 @@ import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs } from './Types'; import { AttributeData } from './AttributeData'; -import { CellData } from './CellData'; +import { CellData, encodeRange } from './CellData'; import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; import { stringFromCodePoint } from '../input/TextDecoder'; @@ -172,18 +172,9 @@ export class LogicalLine implements ILogicalLine { return cell; } const startIndex = this._dataStart + index * Constants.CELL_INDICIES; - let content = this._data[startIndex + Cell.CONTENT]; - if (content & Content.STORED_IN_CHARS_MASK) { - const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; - const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; - (cell as CellData)._setChars(this._chars, start, start + length); - if (!(content & Content.IS_COMBINED_MASK)) { - content &= ~(Content.STORED_IN_CHARS_MASK|Content.CODEPOINT_MASK); - content |= (length > 0 && this._chars.codePointAt(start)) || 0; - } - } else { - (cell as CellData)._setChars('', -1, -1); - } + const content = this._data[startIndex + Cell.CONTENT]; + (cell as CellData)._string = content & Content.STORED_IN_CHARS_MASK + ? this._chars : ''; cell.content = content; cell.fg = this._data[startIndex + Cell.FG]; cell.bg = this._data[startIndex + Cell.BG]; @@ -265,22 +256,33 @@ export class LogicalLine implements ILogicalLine { return this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } - public static _withRange(content: number, start: number, length: number): number { - content &= ~(Content.START_IN_CHARS_MASK | Content.LENGTH_IN_CHARS_MASK); - content |= (start << Content.START_IN_CHARS_SHIFT) | (length << Content.LENGTH_IN_CHARS_SHIFT) | Content.STORED_IN_CHARS_MASK; - return content; - } - 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); + let content = cell.content; + const width = cell.getWidth(); + if (content & Content.STORED_IN_CHARS_MASK) { + this.setCellFromCodepoint(index, 1, width, cell); + if (cell instanceof CellData && cell._string === this._chars) { + // re-use content as-is. + } else if (cell.content & Content.IS_COMBINED_MASK) { + const str = cell.getChars(); + content = encodeRange(content, 0, str.length); + } else { + content = cell.getCode() | (width << Content.WIDTH_SHIFT); + } + this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = content; + return; + } + content = content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); + this.setCellFromCodepoint(index, content, width, cell); + /* if (cell.content & Content.IS_COMBINED_MASK) { const str = cell.combinedData; const start = this._chars.length; const length = str.length; this._chars += str; - this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = LogicalLine._withRange(cell.content, start, length); + this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = encodeRange(cell.content, start, length); } + */ } /** @@ -398,7 +400,7 @@ export class LogicalLine implements ILogicalLine { // append at end - can reuse old combined data. // Preserve value of _charsIsTextValue this._chars += addedStr; - this._data[dindex + Cell.CONTENT] = LogicalLine._withRange(content, oldStart, oldLength + addedLength); + this._data[dindex + Cell.CONTENT] = encodeRange(content, oldStart, oldLength + addedLength); return; } oldCellStr = this._chars.substring(oldStart, oldStart + oldLength); @@ -407,7 +409,7 @@ export class LogicalLine implements ILogicalLine { } this._charsIsTextValue = false; this._chars += oldCellStr + addedStr; - this._data[dindex + Cell.CONTENT] = LogicalLine._withRange(content, oldStrLen, oldCellStr.length + addedLength); + this._data[dindex + Cell.CONTENT] = encodeRange(content, oldStrLen, oldCellStr.length + addedLength); } /** @@ -509,7 +511,7 @@ export class LogicalLine implements ILogicalLine { let cstr = this.getString(i); let clen = cstr.length; const content = this._data[j]; - this._data[j] = LogicalLine._withRange(content, nchars, clen); + this._data[j] = encodeRange(content, nchars, clen); if (clen === 0 && (content >> Content.WIDTH_SHIFT) > 0) { cstr = ' '; clen = 1; diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 92f3cfb639..bf7f340c8c 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -25,14 +25,10 @@ export class CellData extends AttributeData implements ICellData { public bg = 0; public extended: IExtendedAttrs = new ExtendedAttrs(); /** - * Cache for getChars. - * If _stringStart == -1: Use codepoint in this.content. - * If _stringStart == 0 && _stringEnd == -1: Entire _string. - * Otherwise: substring of _string. - */ + * Base string value of cell. + * Only valid if STORED_IN_CHARS_MASK is set in content field. + */ public _string: string = ''; - public _stringStart: number = 0; - public _stringEnd: number = -1; // Whole string public get combinedData(): string { return this.isCombined() ? this.getChars() : ''; } @@ -46,26 +42,22 @@ export class CellData extends AttributeData implements ICellData { } /** JS string of the content. */ public getChars(): string { - if (this._stringStart === 0 && this._stringEnd < 0) { - return this._string; - } - let str; - if (this._stringStart < 0) { - const codePoint = this.content & Content.CODEPOINT_MASK; - str = codePoint ? stringFromCodePoint(codePoint) : ''; - } else { - str = this._string.substring(this._stringStart, this._stringEnd); - } - this._string = str; - this._stringStart = 0; - this._stringEnd = -1; - return str; - } - public _setChars(str: string, strStart: number = 0, strEnd: number = -1): void { - this._string = str; - this._stringStart = strStart; - this._stringEnd = strEnd; + const content = this.content; + if (content & Content.STORED_IN_CHARS_MASK) { + const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + const str = this._string; + return start === 0 && length === str.length ? str + : str.substring(start, start + length); + } + const codePoint = this.content & Content.CODEPOINT_MASK; + if (codePoint) { + // FIXME maybe cache value in _string field? + return stringFromCodePoint(this.content & Content.CODEPOINT_MASK); + } + return ''; } + /** * Codepoint of cell * Note this returns the UTF32 codepoint of single chars, @@ -73,8 +65,17 @@ export class CellData extends AttributeData implements ICellData { * of the last char in string to be in line with code in CharData. */ public getCode(): number { - return !this.isCombined() ? this.content & Content.CODEPOINT_MASK - : this._string.charCodeAt(this._stringEnd < 0 ? this._string.length -1 : this._stringEnd - 1); + const content = this.content; + if (content & Content.STORED_IN_CHARS_MASK) { + const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + const code = this._string.charCodeAt(start + length - 1); + return !code ? 0 + : code < 0xDC000 || code > 0xDFFF || length < 2 ? code + : this._string.codePointAt(start + length - 2) || code; + return code; + } + return content & Content.CODEPOINT_MASK; } /** Set data from CharData */ @@ -107,14 +108,12 @@ export class CellData extends AttributeData implements ICellData { this.content = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } if (combined) { - this._string = value[CHAR_DATA_CHAR_INDEX]; - this._stringStart = 0; - this._stringEnd = -1; - this.content = Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); + const str = value[CHAR_DATA_CHAR_INDEX] + this._string = str; + this.content = encodeRange(Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), + 0, str.length); } else { this._string = ''; - this._stringStart = -1; - this._stringEnd = 0; } } /** Get data as CharData. @@ -180,3 +179,9 @@ export class CellData extends AttributeData implements ICellData { } } + +export function encodeRange(content: number, start: number, length: number): number { + content &= ~(Content.START_IN_CHARS_MASK | Content.LENGTH_IN_CHARS_MASK); + content |= (start << Content.START_IN_CHARS_SHIFT) | (length << Content.LENGTH_IN_CHARS_SHIFT) | Content.STORED_IN_CHARS_MASK; + return content; +} From 5994e067850bc5e0f0ecfec253b61d3335f02f50 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Wed, 23 Sep 2026 18:41:16 -0700 Subject: [PATCH 17/43] Fixes relating to surrogates, combined chars, wide chars. --- src/common/buffer/BufferLine.test.ts | 6 +-- src/common/buffer/BufferLine.ts | 67 ++++++++++++++-------------- src/common/buffer/CellData.ts | 12 ++--- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index a80c984f75..f26b8d9340 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -198,9 +198,9 @@ describe('BufferLine', function(): void { assert.equal(line.length, 10); assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); assert.equal(line.isWrapped, true); - line = new TestBufferLine(10, createCellData(123, 'a', 456), true); + line = new TestBufferLine(10, createCellData(123, 'a', 1), true); assert.equal(line.length, 10); - assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [123, 'a', 456, 'a'.charCodeAt(0)]); + assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [123, 'a', 1, 'a'.charCodeAt(0)]); assert.equal(line.isWrapped, true); }); it('insertCells', function(): void { @@ -661,7 +661,7 @@ describe('BufferLine', function(): void { line.setCell(1, cell); // same eAttr, different codepoint - cell.content = 65; // 'A' + cell.content = 65 | 1 << Content.WIDTH_SHIFT; // 'A' line.setCell(2, cell); // different eAttr diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 68bb5c8dad..b7c89a422c 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -135,14 +135,15 @@ export class LogicalLine implements ILogicalLine { } public showContents(): string { - let result = ''; + let result = '['; for (let i = 0; i < this.length; i++) { if (i > 0) { result += '; '; } result += `#${i}:`; result += this._data[i * Constants.CELL_INDICIES + this._dataStart].toString(16); const s = this.getString(i); - if (s.length > 0) { result += `=${JSON.stringify(s)}`} + if (s.length > 0) { result += `=${JSON.stringify(s)}`; } } + result += ']len:'+this.length; return result; } @@ -245,10 +246,9 @@ export class LogicalLine implements ILogicalLine { return true; } const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; - return (content & Content.WIDTH_MASK) > 0 - && ((content & Content.STORED_IN_CHARS_MASK) - ? (content & Content.LENGTH_IN_CHARS_MASK) === 0 - : (content & Content.CODEPOINT_MASK) === 0); + return ((content & Content.STORED_IN_CHARS_MASK) + ? (content & Content.LENGTH_IN_CHARS_MASK) === 0 + : (content & Content.CODEPOINT_MASK) === 0); } /** Get state of protected flag. */ @@ -263,9 +263,10 @@ export class LogicalLine implements ILogicalLine { this.setCellFromCodepoint(index, 1, width, cell); if (cell instanceof CellData && cell._string === this._chars) { // re-use content as-is. - } else if (cell.content & Content.IS_COMBINED_MASK) { + } else if (content & Content.IS_COMBINED_MASK) { const str = cell.getChars(); - content = encodeRange(content, 0, str.length); + content = encodeRange(content, this._chars.length, str.length); + this._chars += str; } else { content = cell.getCode() | (width << Content.WIDTH_SHIFT); } @@ -274,15 +275,6 @@ export class LogicalLine implements ILogicalLine { } content = content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); this.setCellFromCodepoint(index, content, width, cell); - /* - if (cell.content & Content.IS_COMBINED_MASK) { - const str = cell.combinedData; - const start = this._chars.length; - const length = str.length; - this._chars += str; - this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = encodeRange(cell.content, start, length); - } - */ } /** @@ -314,16 +306,19 @@ export class LogicalLine implements ILogicalLine { } return; } - if (index >= this.length && !isNull) { - this.resizeData(index + 1); + if (index + width > this.length && !isNull) { + this.resizeData(index + width); let j = this._dataStart + this.length * Constants.CELL_INDICIES; - for (let i = this.length; i < index; i++) { + // If wide, we need to clean index+1 in addition. We don't need + // to clear index (as it get overwritten) but this is simple. + const lastToClear = width <= 1 ? index - 1 : index + 1; + for (let i = this.length; i <= lastToClear; i++) { this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; this._data[j + Cell.FG] = 0; this._data[j + Cell.BG] = this.backgroundColor; j += Constants.CELL_INDICIES; } - this.length = index + 1; + this.length = index + width; } if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; @@ -507,18 +502,22 @@ export class LogicalLine implements ILogicalLine { const llen = this.length; let nchars = 0; let j = this._dataStart + Cell.CONTENT; + let prevWasWide = false; for (let i = 0; i < llen; i++) { - let cstr = this.getString(i); + const cstr = this.getString(i); let clen = cstr.length; const content = this._data[j]; this._data[j] = encodeRange(content, nchars, clen); - if (clen === 0 && (content >> Content.WIDTH_SHIFT) > 0) { - cstr = ' '; + const width = content >> Content.WIDTH_SHIFT; + if (clen > 0) { + cellContents.push(cstr); + } else if (!prevWasWide) { + cellContents.push(' '); clen = 1; } - cellContents.push(cstr); j += Constants.CELL_INDICIES; nchars += clen; + prevWasWide = width > 1; } const result = cellContents.join(''); this._charsIsTextValue = true; @@ -951,14 +950,21 @@ export class BufferLine implements IBufferLine { this.length = line.length; } - public getTrimmedLength(noBg: boolean = false): number { + public getTrimmedLength(): number { + return this.validEnd - this.startColumn; + } + + public getNoBgTrimmedLength(): number { + if (this._logicalLine.backgroundColor) { + return this.length; + } const logicalLine = this._logicalLine; const startColumn = this.startColumn; const data = logicalLine._data; for (let i = this.validEnd; --i >= startColumn; ) { const j = logicalLine._dataStart + i * Constants.CELL_INDICIES; if (!logicalLine.isNullChar(i) - || (noBg && (data[j + Cell.BG] & Attributes.CM_MASK))) { + || ((data[j + Cell.BG] & Attributes.CM_MASK))) { i += data[j + Cell.CONTENT] >> Content.WIDTH_SHIFT; return i - startColumn; } @@ -966,13 +972,6 @@ export class BufferLine implements IBufferLine { return startColumn; } - public getNoBgTrimmedLength(): number { - if (this._logicalLine.backgroundColor) { - return this.length; - } - return this.getTrimmedLength(true); - } - public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { this._logicalLine.copyCellsFrom(src._logicalLine, srcCol + src.startColumn, destCol + this.startColumn, length, applyInReverse); diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index bf7f340c8c..c39b2e73cb 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -83,16 +83,17 @@ export class CellData extends AttributeData implements ICellData { this.fg = value[CHAR_DATA_ATTR_INDEX]; this.bg = 0; let combined = false; + const str = value[CHAR_DATA_CHAR_INDEX]; // surrogates and combined strings need special treatment - if (value[CHAR_DATA_CHAR_INDEX].length > 2) { + if (str.length > 2) { combined = true; } - else if (value[CHAR_DATA_CHAR_INDEX].length === 2) { - const code = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0); + else if (str.length === 2) { + const code = str.charCodeAt(0); // if the 2-char string is a surrogate create single codepoint // everything else is combined if (0xD800 <= code && code <= 0xDBFF) { - const second = value[CHAR_DATA_CHAR_INDEX].charCodeAt(1); + const second = str.charCodeAt(1); if (0xDC00 <= second && second <= 0xDFFF) { this.content = ((code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } @@ -105,10 +106,9 @@ export class CellData extends AttributeData implements ICellData { } } else { - this.content = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); + this.content = str.charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } if (combined) { - const str = value[CHAR_DATA_CHAR_INDEX] this._string = str; this.content = encodeRange(Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), 0, str.length); From 06fb650b92adc5b38dd5d0e044885d3e460db1c8 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 24 Sep 2026 09:40:20 -0700 Subject: [PATCH 18/43] Remove trimRight argument from translateBufferLineToStringWithWrap in addon-search The only time the argument is non-true is in one test-case. This removes one place where translateToString is called with trimRight false/ --- addons/addon-search/src/SearchEngine.ts | 2 +- .../addon-search/src/SearchLineCache.test.ts | 27 +++++++++---------- addons/addon-search/src/SearchLineCache.ts | 4 +-- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index 1760bc2bd1..c6625b5cc3 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -269,7 +269,7 @@ export class SearchEngine { } let cache = this._lineCache.getLineFromCache(row); if (!cache) { - cache = this._lineCache.translateBufferLineToStringWithWrap(row, true); + cache = this._lineCache.translateBufferLineToStringWithWrap(row); this._lineCache.setLineInCache(row, cache); } const [stringLine, offsets] = cache; diff --git a/addons/addon-search/src/SearchLineCache.test.ts b/addons/addon-search/src/SearchLineCache.test.ts index 77f64c81ec..086b04d7b1 100644 --- a/addons/addon-search/src/SearchLineCache.test.ts +++ b/addons/addon-search/src/SearchLineCache.test.ts @@ -112,25 +112,22 @@ describe('SearchLineCache', () => { describe('translateBufferLineToStringWithWrap', () => { it('should translate a single line without wrapping', async () => { await writeP(terminal, 'Hello World'); - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.equal(result[0], 'Hello World'); assert.deepEqual(result[1], [0]); }); it('should handle trimRight parameter', async () => { await writeP(terminal, 'Hello World '); - const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true); - const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false); + const resultTrimmed = cache.translateBufferLineToStringWithWrap(0); assert.equal(resultTrimmed[0].trimEnd(), 'Hello World'); - assert.isTrue(resultNotTrimmed[0].startsWith('Hello World ')); - assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer'); }); it('should handle wrapped lines', async () => { const longText = 'A'.repeat(200); await writeP(terminal, longText); - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.equal(result[0], longText); assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping'); assert.equal(result[1][0], 0, 'first offset should be 0'); @@ -138,19 +135,19 @@ describe('SearchLineCache', () => { it('should handle wide characters', async () => { await writeP(terminal, 'Hello 世界'); - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.equal(result[0], 'Hello 世界'); assert.deepEqual(result[1], [0]); }); it('should handle empty lines', () => { - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.equal(result[0], ''); assert.deepEqual(result[1], [0]); }); it('should handle lines beyond buffer', () => { - const result = cache.translateBufferLineToStringWithWrap(1000, true); + const result = cache.translateBufferLineToStringWithWrap(1000); assert.equal(result[0], ''); assert.deepEqual(result[1], [0]); }); @@ -160,9 +157,9 @@ describe('SearchLineCache', () => { await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n'); await writeP(terminal, 'Line 3'); - const result1 = cache.translateBufferLineToStringWithWrap(0, true); - const result2 = cache.translateBufferLineToStringWithWrap(1, true); - const result3 = cache.translateBufferLineToStringWithWrap(2, true); + const result1 = cache.translateBufferLineToStringWithWrap(0); + const result2 = cache.translateBufferLineToStringWithWrap(1); + const result3 = cache.translateBufferLineToStringWithWrap(2); assert.equal(result1[0], 'Line 1'); assert.equal(result2[0], 'Line 2 with some longer content that might wrap'); @@ -262,7 +259,7 @@ describe('SearchLineCache', () => { describe('integration with real terminal content', () => { it('should correctly translate real buffer content', async () => { await writeP(terminal, 'Hello World'); - const cached = cache.translateBufferLineToStringWithWrap(0, true); + const cached = cache.translateBufferLineToStringWithWrap(0); const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || ''; assert.equal(cached[0], directTranslation); @@ -271,14 +268,14 @@ describe('SearchLineCache', () => { it('should handle real wrapped content correctly', async () => { const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache'; await writeP(terminal, longContent); - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.equal(result[0], longContent); assert.isTrue(result[1].length > 1, 'should have wrapped'); }); it('should work with real escape sequences', async () => { await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter'); - const result = cache.translateBufferLineToStringWithWrap(0, true); + const result = cache.translateBufferLineToStringWithWrap(0); assert.include(result[0], 'Before'); assert.include(result[0], 'Red Text'); assert.include(result[0], 'After'); diff --git a/addons/addon-search/src/SearchLineCache.ts b/addons/addon-search/src/SearchLineCache.ts index 526f4bfcc7..921f81d37a 100644 --- a/addons/addon-search/src/SearchLineCache.ts +++ b/addons/addon-search/src/SearchLineCache.ts @@ -106,14 +106,14 @@ export class SearchLineCache extends Disposable { * @param lineIndex The index of the line being translated. * @param trimRight Whether to trim whitespace to the right. */ - public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { + public translateBufferLineToStringWithWrap(lineIndex: number): LineCacheEntry { const strings = []; const lineOffsets = [0]; let line = this._terminal.buffer.active.getLine(lineIndex); while (line) { const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; - let string = line.translateToString(!lineWrapsToNext && trimRight); + let string = line.translateToString(!lineWrapsToNext); if (lineWrapsToNext && nextLine) { const lastCell = line.getCell(line.length - 1); const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1; From 5af9ecdf2da20ffcd3a7ec5ff2f70c4e11a3ab19 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 24 Sep 2026 10:57:46 -0700 Subject: [PATCH 19/43] Remove needless recursion in SearchEngine.ts --- addons/addon-search/src/SearchEngine.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index c6625b5cc3..c569ba927d 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -250,11 +250,11 @@ export class SearchEngine { * @returns The search result if it was found. */ private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined { - const row = searchPosition.startRow; - const col = searchPosition.startCol; + let row = searchPosition.startRow; + let col = searchPosition.startCol; // Ignore wrapped lines, only consider on unwrapped line (first row of command string). - const firstLine = this._terminal.buffer.active.getLine(row); + let firstLine = this._terminal.buffer.active.getLine(row); if (firstLine?.isWrapped) { if (isReverseSearch) { searchPosition.startCol += this._terminal.cols; @@ -263,9 +263,13 @@ export class SearchEngine { // This will iterate until we find the line start. // When we find it, we will search using the calculated start column. - searchPosition.startRow--; - searchPosition.startCol += this._terminal.cols; - return this._findInLine(term, searchPosition, searchOptions); + do { + row--; + col += this._terminal.cols; + firstLine = this._terminal.buffer.active.getLine(row); + } while (firstLine?.isWrapped); + searchPosition.startRow = row; + searchPosition.startCol = col; } let cache = this._lineCache.getLineFromCache(row); if (!cache) { From 910e235ed103c26f66d685ce42785d3211be9cc8 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 24 Sep 2026 11:27:57 -0700 Subject: [PATCH 20/43] Remove parameter comment for removed trimRight parameter. --- addons/addon-search/src/SearchLineCache.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/addons/addon-search/src/SearchLineCache.ts b/addons/addon-search/src/SearchLineCache.ts index 921f81d37a..5236945dd2 100644 --- a/addons/addon-search/src/SearchLineCache.ts +++ b/addons/addon-search/src/SearchLineCache.ts @@ -104,7 +104,6 @@ export class SearchLineCache extends Disposable { * function is useful for getting the actual text underneath the raw selection * position. * @param lineIndex The index of the line being translated. - * @param trimRight Whether to trim whitespace to the right. */ public translateBufferLineToStringWithWrap(lineIndex: number): LineCacheEntry { const strings = []; From 395bf7fdeb1f92d72263d0a9c4dc327b5d010fe6 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 25 Sep 2026 09:45:50 -0700 Subject: [PATCH 21/43] Move BufferColumn and LogicalColumn to Types.ts. --- src/common/buffer/BufferLine.ts | 16 +--------------- src/common/buffer/Types.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index b7c89a422c..5646f9bfdf 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 './Types'; +import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs, BufferColumn, LogicalColumn } from './Types'; import { AttributeData } from './AttributeData'; import { CellData, encodeRange } from './CellData'; import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; @@ -22,20 +22,6 @@ const enum Constants { CLEANUP_THRESHOLD = 2 } -/* - * Column count within current visible BufferLine(row). - * The left-most column is column 0. - */ -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.) - */ -export type LogicalColumn = number; - /** * Cell member indices. * diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index fb2bb6cb29..81c4e03782 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -14,6 +14,20 @@ export type BufferIndex = [number, number]; export type CharData = [attr: number, char: string, width: number, code: number]; +/* + * Column count within current visible BufferLine(row). + * The left-most column is column 0. + */ +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.) + */ +export type LogicalColumn = number; + export interface IExtendedAttrs { ext: number; underlineStyle: UnderlineStyle; From f707e8ead16b15568f1e4b00ea028fc375974d60 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 25 Sep 2026 09:55:24 -0700 Subject: [PATCH 22/43] Rename LogicalLine's getTrimmedString to asString. --- src/common/buffer/BufferLine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 5646f9bfdf..e4028d8efe 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -482,7 +482,7 @@ export class LogicalLine implements ILogicalLine { } */ - public getTrimmedString(startCol: LogicalColumn = 0, endCol: LogicalColumn = -1): string { + public asString(startCol: LogicalColumn = 0, endCol: LogicalColumn = -1): string { if (!this._charsIsTextValue) { const cellContents: string[] = []; const llen = this.length; @@ -1104,7 +1104,7 @@ export class BufferLine implements IBufferLine { validEnd = this._logicalLine.length; } validEnd = Math.min(endCol, validEnd); - let result = lline.getTrimmedString(startCol, validEnd); + let result = lline.asString(startCol, validEnd); const paddingNeeded = trimRight ? 0 : Math.max(0, endCol - validEnd); if (paddingNeeded) { result += WHITESPACE_CELL_CHAR.repeat(paddingNeeded); From 3967a937dd9e6b68ed26af7fd3f43738d6453647 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 26 Sep 2026 13:45:26 -0700 Subject: [PATCH 23/43] Speed up addon-search, especifically _stringLengthToBufferSize. Added new offsetToString function in LogicalLine. Also, by-pass public API to avoid allocating BufferLineApiView. --- addons/addon-search/src/SearchEngine.ts | 49 ++++++++----------------- src/browser/TestUtils.test.ts | 5 ++- src/common/buffer/BufferLine.ts | 19 +++++++--- src/common/buffer/Types.ts | 2 + 4 files changed, 35 insertions(+), 40 deletions(-) diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index c569ba927d..f1428221a0 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -6,6 +6,8 @@ import type { Terminal } from '@xterm/xterm'; import type { ISearchOptions } from '@xterm/addon-search'; import type { SearchLineCache } from './SearchLineCache'; +import { CellData } from 'common/buffer/CellData'; +import type { ITerminal } from 'browser/Types'; /** * Represents the position to start a search from. @@ -45,7 +47,10 @@ export class SearchEngine { constructor( private readonly _terminal: Terminal, private readonly _lineCache: SearchLineCache - ) {} + ) { + this._core = (this._terminal as any)._core; + } + private _core: ITerminal; /** * Find the first occurrence of a term starting from a specific position. @@ -254,7 +259,7 @@ export class SearchEngine { let col = searchPosition.startCol; // Ignore wrapped lines, only consider on unwrapped line (first row of command string). - let firstLine = this._terminal.buffer.active.getLine(row); + let firstLine = this._core.buffer.lines.get(row); if (firstLine?.isWrapped) { if (isReverseSearch) { searchPosition.startCol += this._terminal.cols; @@ -266,7 +271,7 @@ export class SearchEngine { do { row--; col += this._terminal.cols; - firstLine = this._terminal.buffer.active.getLine(row); + firstLine = this._core.buffer.lines.get(row); } while (firstLine?.isWrapped); searchPosition.startRow = row; searchPosition.startCol = col; @@ -281,6 +286,7 @@ export class SearchEngine { const offset = this._bufferColsToStringOffset(row, col); let searchTerm = term; let searchStringLine = stringLine; + // const stringLine = firstLine?.logical().asString(); if (!searchOptions.regex) { searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); @@ -345,12 +351,13 @@ export class SearchEngine { } private _stringLengthToBufferSize(row: number, offset: number): number { - const line = this._terminal.buffer.active.getLine(row); + const line = this._core.buffer.lines.get(row); if (!line) { return 0; } + const cell = new CellData(); for (let i = 0; i < offset; i++) { - const cell = line.getCell(i); + line.loadCell(i, cell); if (!cell) { break; } @@ -359,38 +366,14 @@ export class SearchEngine { if (char.length > 1) { offset -= char.length - 1; } - // Adjust the searchIndex for empty characters following wide unicode - // chars (eg. CJK) - const nextCell = line.getCell(i + 1); - if (nextCell && nextCell.getWidth() === 0) { - offset++; - } + const width = cell.getWidth(); + if (width > 1) { offset += width - 1; } } return offset; } private _bufferColsToStringOffset(startRow: number, cols: number): number { - let lineIndex = startRow; - let offset = 0; - let line = this._terminal.buffer.active.getLine(lineIndex); - while (cols > 0 && line) { - for (let i = 0; i < cols && i < this._terminal.cols; i++) { - const cell = line.getCell(i); - if (!cell) { - break; - } - if (cell.getWidth()) { - // Treat null characters as whitespace to align with the translateToString API - offset += cell.getCode() === 0 ? 1 : cell.getChars().length; - } - } - lineIndex++; - line = this._terminal.buffer.active.getLine(lineIndex); - if (line && !line.isWrapped) { - break; - } - cols -= this._terminal.cols; - } - return offset; + const line = this._core.buffer.lines.get(startRow)!; + return line ? line.logical().offsetInString(cols) : 0; } } diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 27a13d4da0..f2ff58149f 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -11,6 +11,7 @@ import { IAttributeData, IBuffer, IBufferLine, IBufferSet, ICellData } from '../ import { ICircularList } from '../common/CircularList'; import { XtermListener, ICharset, ITerminalOptions, ColorIndex } from '../common/Types'; import { Buffer } from '../common/buffer/Buffer'; +import { LogicalLine } from '../common/buffer/BufferLine'; import * as Browser from '../common/Platform'; import { CoreBrowserTerminal } from './CoreBrowserTerminal'; import { IUnicodeService, IOptionsService, ICoreService, IMouseStateService } from '../common/services/Services'; @@ -256,8 +257,8 @@ export class MockBuffer implements IBuffer { public setLines(lines: ICircularList): void { this.lines = lines; } - public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return Buffer.prototype.getBlankLine.apply(this, arguments as any); + public getBlankLine(attr: IAttributeData, logicalLine?: LogicalLine): IBufferLine { + return Buffer.prototype.getBlankLine(attr, logicalLine); } public getNullCell(attr?: IAttributeData): ICellData { throw new Error('Method not implemented.'); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e4028d8efe..1b7b482830 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -206,11 +206,11 @@ export class LogicalLine implements ILogicalLine { return ''; } -/** - * Get codepoint of the cell. - * To be in line with `code` in CharData this either returns - * a single UTF32 codepoint or the last codepoint of a combined string. - */ + /** + * Get codepoint of the cell. + * 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: LogicalColumn): number { const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.STORED_IN_CHARS_MASK) { @@ -523,6 +523,15 @@ export class LogicalLine implements ILogicalLine { } return this._chars; } + + public offsetInString(index: LogicalColumn): number { + if (!this._charsIsTextValue) { this.asString(); } + if (index >= this.length) { + return this._chars.length; + } + const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + return (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + } } /** diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 81c4e03782..d3cd9b8af7 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -129,6 +129,8 @@ export interface ICellData extends IAttributeData { } export interface ILogicalLine { + asString(startCol?: LogicalColumn, endCol?: LogicalColumn): string; + offsetInString(index: LogicalColumn): number; } /** From 374764651e958ef24e5c7a2866d63f18771680da Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 26 Sep 2026 15:09:50 -0700 Subject: [PATCH 24/43] Speed up BufferApiView.getLine and remove BufferLineApiView Instead, have BufferLine implement IBufferApi by adding a getLine method. --- src/common/buffer/BufferLine.ts | 15 +++++++++++++ src/common/public/BufferApiView.ts | 6 +----- src/common/public/BufferLineApiView.ts | 29 -------------------------- 3 files changed, 16 insertions(+), 34 deletions(-) delete mode 100644 src/common/public/BufferLineApiView.ts diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 1b7b482830..a1ce960378 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -733,6 +733,21 @@ export class BufferLine implements IBufferLine { return lline.loadCell(lcolumn, cell); } + /** + * Convenience wrapper around loadCell, used by public API. + */ + public getCell(x: number, cell?: ICellData): ICellData | undefined { + if (x < 0 || x >= this.length) { + return undefined; + } + + if (cell) { + this.loadCell(x, cell); + return cell; + } + return this.loadCell(x, new CellData()); + } + public getExtended(index: number): IExtendedAttrs | undefined { const lline = this._logicalLine; const lcolumn = index + this.startColumn; diff --git a/src/common/public/BufferApiView.ts b/src/common/public/BufferApiView.ts index a2f838ccb0..2d4eafc678 100644 --- a/src/common/public/BufferApiView.ts +++ b/src/common/public/BufferApiView.ts @@ -5,7 +5,6 @@ import { IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi } from '@xterm/xterm'; import { IBuffer } from '../buffer/Types'; -import { BufferLineApiView } from './BufferLineApiView'; import { CellData } from '../buffer/CellData'; export class BufferApiView implements IBufferApi { @@ -26,10 +25,7 @@ export class BufferApiView implements IBufferApi { public get length(): number { return this._buffer.lines.length; } public getLine(y: number): IBufferLineApi | undefined { const line = this._buffer.lines.get(y); - if (!line) { - return undefined; - } - return new BufferLineApiView(line); + return line && line as unknown as IBufferLineApi; } public getNullCell(): IBufferCellApi { return new CellData(); } } diff --git a/src/common/public/BufferLineApiView.ts b/src/common/public/BufferLineApiView.ts deleted file mode 100644 index bd822bfb5c..0000000000 --- a/src/common/public/BufferLineApiView.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) 2021 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { CellData } from '../buffer/CellData'; -import { IBufferLine, ICellData } from '../buffer/Types'; -import { IBufferCell as IBufferCellApi, IBufferLine as IBufferLineApi } from '@xterm/xterm'; - -export class BufferLineApiView implements IBufferLineApi { - constructor(private _line: IBufferLine) { } - - public get isWrapped(): boolean { return this._line.isWrapped; } - public get length(): number { return this._line.length; } - public getCell(x: number, cell?: IBufferCellApi): IBufferCellApi | undefined { - if (x < 0 || x >= this._line.length) { - return undefined; - } - - if (cell) { - this._line.loadCell(x, cell as unknown as ICellData); - return cell; - } - return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi; - } - public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string { - return this._line.translateToString(trimRight, startColumn, endColumn); - } -} From 8f20617f834c63e25d16d8e35c77ed2060532db3 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sat, 26 Sep 2026 18:08:28 -0700 Subject: [PATCH 25/43] Update serialize test since BufferLineApiView class is now gone --- addons/addon-serialize/test/SerializeAddon.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index ce84b4fcd7..50c1c92c6c 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -48,7 +48,7 @@ test.describe('SerializeAddon', () => { const lines = []; for (let i = 0; i < buffer.length; i++) { // Do this intentionally to get content of underlining source - const bufferLine = buffer.getLine(i)._line; + const bufferLine = buffer.getLine(i); if (bufferLine.isWrapped) { lines.push({ startColumn: bufferLine.startColumn, length: bufferLine.length}); } else { @@ -60,7 +60,7 @@ test.describe('SerializeAddon', () => { if (key === '_data') { return new Uint32Array(value.buffer, 0, logical.length * 3); } - return value; + return value })); } } From 7cfa5758120f14bb0120ca00a883cc2cde65de39 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 27 Sep 2026 13:11:08 -0700 Subject: [PATCH 26/43] Fix bug in CellData function getChars. --- src/common/buffer/CellData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index c39b2e73cb..cbe8d6df2f 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -69,7 +69,7 @@ export class CellData extends AttributeData implements ICellData { if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; - const code = this._string.charCodeAt(start + length - 1); + const code = length ? this._string.charCodeAt(start + length - 1) : 0; return !code ? 0 : code < 0xDC000 || code > 0xDFFF || length < 2 ? code : this._string.codePointAt(start + length - 2) || code; From c597d626d9e5438bed149703fc68fbbbe43fb9ef Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 27 Sep 2026 14:22:41 -0700 Subject: [PATCH 27/43] Add forEachBufferLine method to LogicalLine. Also, add startColumn to IBufferLine. --- src/common/buffer/BufferLine.ts | 5 +++++ src/common/buffer/Types.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index a1ce960378..8e0e60045e 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -99,6 +99,11 @@ export class LogicalLine implements ILogicalLine { this.length = 0; this._extendedAttrs = {}; } + public forEachBufferLine(callback: (line: IBufferLine) => void): void { + for (let line = this.firstBufferLine; line; line = line.nextBufferLine) { + callback(line); + } + } /** * @internal diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index d3cd9b8af7..b111e2d798 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -129,6 +129,7 @@ export interface ICellData extends IAttributeData { } export interface ILogicalLine { + forEachBufferLine(callback: (line: IBufferLine) => void): void; asString(startCol?: LogicalColumn, endCol?: LogicalColumn): string; offsetInString(index: LogicalColumn): number; } @@ -138,6 +139,7 @@ export interface ILogicalLine { */ export interface IBufferLine { length: number; + startColumn: number; logical(): ILogicalLine; get isWrapped(): boolean; get(index: number): CharData; From 8e5113b2b5ce0351aa28c20efeab02cec6630d39 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Sun, 27 Sep 2026 14:23:43 -0700 Subject: [PATCH 28/43] Remove SearchLineCache from addon-search. It is no longer needed: We just use the asString method from ILogicalLine. That method now caches the result inexpensively. --- .../src/DecorationManager.test.ts | 3 +- addons/addon-search/src/SearchAddon.ts | 5 +- addons/addon-search/src/SearchEngine.test.ts | 9 +- addons/addon-search/src/SearchEngine.ts | 22 +- .../addon-search/src/SearchLineCache.test.ts | 284 ------------------ addons/addon-search/src/SearchLineCache.ts | 135 --------- 6 files changed, 10 insertions(+), 448 deletions(-) delete mode 100644 addons/addon-search/src/SearchLineCache.test.ts delete mode 100644 addons/addon-search/src/SearchLineCache.ts diff --git a/addons/addon-search/src/DecorationManager.test.ts b/addons/addon-search/src/DecorationManager.test.ts index 26e40dbc20..de56c69fc9 100644 --- a/addons/addon-search/src/DecorationManager.test.ts +++ b/addons/addon-search/src/DecorationManager.test.ts @@ -6,7 +6,6 @@ import { assert } from 'chai'; import { DecorationManager } from './DecorationManager'; import { SearchEngine } from './SearchEngine'; -import { SearchLineCache } from './SearchLineCache'; import { Terminal } from 'browser/public/Terminal'; import type { ISearchDecorationOptions } from '@xterm/addon-search'; import type { IDecorationOptions } from '@xterm/xterm'; @@ -29,7 +28,7 @@ describe('DecorationManager', () => { it('should split highlight decorations for a wrapped match', async () => { await writeP(terminal, '0123456789abcde'); - const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal))); + const searchEngine = new SearchEngine(terminal); const match = searchEngine.find('9abc', 0, 0); assert.ok(match); diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index e5fd31cfb0..a7ce7cc588 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -8,7 +8,6 @@ import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, IS import { Emitter, type IEvent } from 'common/Event'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { disposableTimeout } from 'common/Async'; -import { SearchLineCache } from './SearchLineCache'; import { SearchState } from './SearchState'; import { SearchEngine, type ISearchResult } from './SearchEngine'; import { DecorationManager } from './DecorationManager'; @@ -34,7 +33,6 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp private _terminal: Terminal | undefined; private _highlightLimit: number; private _highlightTimeout = this._register(new MutableDisposable()); - private _lineCache = this._register(new MutableDisposable()); // Component instances private _state = new SearchState(); @@ -59,8 +57,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp public activate(terminal: Terminal): void { this._terminal = terminal; - this._lineCache.value = new SearchLineCache(terminal); - this._engine = new SearchEngine(terminal, this._lineCache.value); + this._engine = new SearchEngine(terminal); this._decorationManager = new DecorationManager(terminal); this._register(this._terminal.onWriteParsed(() => this._updateMatches())); this._register(this._terminal.onResize(() => this._updateMatches())); diff --git a/addons/addon-search/src/SearchEngine.test.ts b/addons/addon-search/src/SearchEngine.test.ts index b3cea8f54f..3b07352869 100644 --- a/addons/addon-search/src/SearchEngine.test.ts +++ b/addons/addon-search/src/SearchEngine.test.ts @@ -4,7 +4,6 @@ */ import { assert } from 'chai'; import { SearchEngine } from './SearchEngine'; -import { SearchLineCache } from './SearchLineCache'; import { Terminal } from 'browser/public/Terminal'; import type { ISearchOptions } from '@xterm/addon-search'; import { DisposableStore } from 'common/Lifecycle'; @@ -16,14 +15,12 @@ function writeP(terminal: Terminal, data: string): Promise { describe('SearchEngine', () => { let store: DisposableStore; let terminal: Terminal; - let lineCache: SearchLineCache; let searchEngine: SearchEngine; beforeEach(() => { store = new DisposableStore(); terminal = store.add(new Terminal({ cols: 80, rows: 24 })); - lineCache = store.add(new SearchLineCache(terminal)); - searchEngine = new SearchEngine(terminal, lineCache); + searchEngine = new SearchEngine(terminal); }); afterEach(() => { @@ -663,9 +660,6 @@ describe('SearchEngine', () => { it('should use cache for line translation', async () => { await writeP(terminal, 'Hello World'); - // Initialize cache - lineCache.initLinesCache(); - const result1 = searchEngine.find('World', 0, 0); const result2 = searchEngine.find('World', 0, 0); @@ -688,7 +682,6 @@ describe('SearchEngine', () => { it('should work correctly with cache invalidation', async () => { await writeP(terminal, 'Initial text'); - lineCache.initLinesCache(); const result1 = searchEngine.find('Initial', 0, 0); assert.notStrictEqual(result1, undefined); diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index f1428221a0..62ccb4a0cd 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -5,7 +5,6 @@ import type { Terminal } from '@xterm/xterm'; import type { ISearchOptions } from '@xterm/addon-search'; -import type { SearchLineCache } from './SearchLineCache'; import { CellData } from 'common/buffer/CellData'; import type { ITerminal } from 'browser/Types'; @@ -45,8 +44,7 @@ const enum Constants { */ export class SearchEngine { constructor( - private readonly _terminal: Terminal, - private readonly _lineCache: SearchLineCache + private readonly _terminal: Terminal ) { this._core = (this._terminal as any)._core; } @@ -69,8 +67,6 @@ export class SearchEngine { throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`); } - this._lineCache.initLinesCache(); - const searchPosition: ISearchPosition = { startRow, startCol @@ -120,8 +116,6 @@ export class SearchEngine { } } - this._lineCache.initLinesCache(); - const searchPosition: ISearchPosition = { startRow, startCol @@ -182,7 +176,6 @@ export class SearchEngine { const startCol = this._terminal.cols; const isReverseSearch = true; - this._lineCache.initLinesCache(); const searchPosition: ISearchPosition = { startRow, startCol @@ -276,17 +269,12 @@ export class SearchEngine { searchPosition.startRow = row; searchPosition.startCol = col; } - let cache = this._lineCache.getLineFromCache(row); - if (!cache) { - cache = this._lineCache.translateBufferLineToStringWithWrap(row); - this._lineCache.setLineInCache(row, cache); - } - const [stringLine, offsets] = cache; + const logical = firstLine?.logical(); + const stringLine = logical ? logical.asString() : ''; const offset = this._bufferColsToStringOffset(row, col); let searchTerm = term; let searchStringLine = stringLine; - // const stringLine = firstLine?.logical().asString(); if (!searchOptions.regex) { searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); @@ -324,6 +312,10 @@ export class SearchEngine { if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { return; } + const offsets: number[] = []; + logical?.forEachBufferLine((line) => { + offsets.push(logical.offsetInString(line.startColumn)); + }); // Adjust the row number and search index if needed since a "line" of text can span multiple // rows diff --git a/addons/addon-search/src/SearchLineCache.test.ts b/addons/addon-search/src/SearchLineCache.test.ts deleted file mode 100644 index 086b04d7b1..0000000000 --- a/addons/addon-search/src/SearchLineCache.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Copyright (c) 2024 The xterm.js authors. All rights reserved. - * @license MIT - */ -import { assert } from 'chai'; -import { SearchLineCache, LineCacheEntry } from './SearchLineCache'; -import { Terminal } from 'browser/public/Terminal'; -import { timeout } from 'common/Async'; - -function writeP(terminal: Terminal, data: string): Promise { - return new Promise(r => terminal.write(data, r)); -} - -describe('SearchLineCache', () => { - let terminal: Terminal; - let cache: SearchLineCache; - - beforeEach(() => { - terminal = new Terminal({ cols: 80, rows: 24 }); - cache = new SearchLineCache(terminal); - }); - - afterEach(() => { - cache.dispose(); - terminal.dispose(); - }); - - describe('constructor', () => { - it('should create a SearchLineCache instance', () => { - assert.instanceOf(cache, SearchLineCache); - }); - - it('should start with no cache initialized', () => { - assert.equal(cache.getLineFromCache(0), undefined); - }); - }); - - describe('initLinesCache', () => { - it('should initialize the lines cache array', () => { - cache.initLinesCache(); - assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty'); - }); - - it('should not reinitialize if cache already exists', () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - cache.initLinesCache(); - - const entry = cache.getLineFromCache(0); - assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry'); - }); - - it('should set up TTL timeout', () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization'); - }); - }); - - describe('getLineFromCache', () => { - it('should return undefined when cache is not initialized', () => { - assert.equal(cache.getLineFromCache(0), undefined); - assert.equal(cache.getLineFromCache(10), undefined); - }); - - it('should return undefined for unset entries when cache is initialized', () => { - cache.initLinesCache(); - assert.equal(cache.getLineFromCache(0), undefined); - assert.equal(cache.getLineFromCache(50), undefined); - }); - - it('should return cached entries', () => { - cache.initLinesCache(); - const entry: LineCacheEntry = ['test content', [0]]; - cache.setLineInCache(5, entry); - - assert.deepEqual(cache.getLineFromCache(5), entry); - }); - }); - - describe('setLineInCache', () => { - it('should not set entries when cache is not initialized', () => { - const entry: LineCacheEntry = ['test content', [0]]; - cache.setLineInCache(0, entry); - - assert.equal(cache.getLineFromCache(0), undefined); - }); - - it('should set entries when cache is initialized', () => { - cache.initLinesCache(); - const entry: LineCacheEntry = ['test content', [0]]; - cache.setLineInCache(10, entry); - - assert.deepEqual(cache.getLineFromCache(10), entry); - }); - - it('should overwrite existing entries', () => { - cache.initLinesCache(); - const entry1: LineCacheEntry = ['first content', [0]]; - const entry2: LineCacheEntry = ['second content', [0]]; - - cache.setLineInCache(0, entry1); - assert.deepEqual(cache.getLineFromCache(0), entry1); - - cache.setLineInCache(0, entry2); - assert.deepEqual(cache.getLineFromCache(0), entry2); - }); - }); - - describe('translateBufferLineToStringWithWrap', () => { - it('should translate a single line without wrapping', async () => { - await writeP(terminal, 'Hello World'); - const result = cache.translateBufferLineToStringWithWrap(0); - assert.equal(result[0], 'Hello World'); - assert.deepEqual(result[1], [0]); - }); - - it('should handle trimRight parameter', async () => { - await writeP(terminal, 'Hello World '); - const resultTrimmed = cache.translateBufferLineToStringWithWrap(0); - - assert.equal(resultTrimmed[0].trimEnd(), 'Hello World'); - }); - - it('should handle wrapped lines', async () => { - const longText = 'A'.repeat(200); - await writeP(terminal, longText); - const result = cache.translateBufferLineToStringWithWrap(0); - assert.equal(result[0], longText); - assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping'); - assert.equal(result[1][0], 0, 'first offset should be 0'); - }); - - it('should handle wide characters', async () => { - await writeP(terminal, 'Hello 世界'); - const result = cache.translateBufferLineToStringWithWrap(0); - assert.equal(result[0], 'Hello 世界'); - assert.deepEqual(result[1], [0]); - }); - - it('should handle empty lines', () => { - const result = cache.translateBufferLineToStringWithWrap(0); - assert.equal(result[0], ''); - assert.deepEqual(result[1], [0]); - }); - - it('should handle lines beyond buffer', () => { - const result = cache.translateBufferLineToStringWithWrap(1000); - assert.equal(result[0], ''); - assert.deepEqual(result[1], [0]); - }); - - it('should handle complex wrapped content', async () => { - await writeP(terminal, 'Line 1\r\n'); - await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n'); - await writeP(terminal, 'Line 3'); - - const result1 = cache.translateBufferLineToStringWithWrap(0); - const result2 = cache.translateBufferLineToStringWithWrap(1); - const result3 = cache.translateBufferLineToStringWithWrap(2); - - assert.equal(result1[0], 'Line 1'); - assert.equal(result2[0], 'Line 2 with some longer content that might wrap'); - assert.equal(result3[0], 'Line 3'); - }); - }); - - describe('cache invalidation', () => { - it('should invalidate cache on line feed', async () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]); - - terminal.write('test\r\n'); - - await timeout(10); - assert.equal(cache.getLineFromCache(0), undefined); - }); - - it('should invalidate cache on cursor move', async () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]); - - await writeP(terminal, 'some text'); - await timeout(10); - assert.equal(cache.getLineFromCache(0), undefined); - }); - - it('should invalidate cache on resize', async () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]); - - terminal.resize(100, 30); - - await timeout(10); - assert.equal(cache.getLineFromCache(0), undefined); - }); - }); - - describe('disposal', () => { - it('should clean up resources on dispose', () => { - cache.initLinesCache(); - cache.setLineInCache(0, ['test', [0]]); - - assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]); - - cache.dispose(); - - assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal'); - }); - - it('should be safe to dispose multiple times', () => { - cache.initLinesCache(); - cache.dispose(); - cache.dispose(); - - assert.equal(cache.getLineFromCache(0), undefined); - }); - }); - - describe('LineCacheEntry type', () => { - it('should handle complex line offsets', () => { - const entry: LineCacheEntry = [ - 'A very long line that wraps multiple times across several terminal lines', - [0, 20, 40, 60] - ]; - - cache.initLinesCache(); - cache.setLineInCache(0, entry); - - const retrieved = cache.getLineFromCache(0); - assert.deepEqual(retrieved, entry); - assert.equal(retrieved![0].length, 72); - assert.equal(retrieved![1].length, 4); - }); - - it('should handle unicode characters in cache entries', () => { - const entry: LineCacheEntry = [ - 'Hello 世界 🌍 测试', - [0] - ]; - - cache.initLinesCache(); - cache.setLineInCache(0, entry); - - const retrieved = cache.getLineFromCache(0); - assert.deepEqual(retrieved, entry); - assert.equal(retrieved![0], 'Hello 世界 🌍 测试'); - }); - }); - - describe('integration with real terminal content', () => { - it('should correctly translate real buffer content', async () => { - await writeP(terminal, 'Hello World'); - const cached = cache.translateBufferLineToStringWithWrap(0); - const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || ''; - - assert.equal(cached[0], directTranslation); - }); - - it('should handle real wrapped content correctly', async () => { - const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache'; - await writeP(terminal, longContent); - const result = cache.translateBufferLineToStringWithWrap(0); - assert.equal(result[0], longContent); - assert.isTrue(result[1].length > 1, 'should have wrapped'); - }); - - it('should work with real escape sequences', async () => { - await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter'); - const result = cache.translateBufferLineToStringWithWrap(0); - assert.include(result[0], 'Before'); - assert.include(result[0], 'Red Text'); - assert.include(result[0], 'After'); - }); - }); -}); diff --git a/addons/addon-search/src/SearchLineCache.ts b/addons/addon-search/src/SearchLineCache.ts deleted file mode 100644 index 5236945dd2..0000000000 --- a/addons/addon-search/src/SearchLineCache.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { Terminal } from '@xterm/xterm'; -import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; -import { disposableTimeout } from 'common/Async'; - -export type LineCacheEntry = [ - /** - * The string representation of a line (as opposed to the buffer cell representation). - */ - lineAsString: string, - /** - * The offsets where each line starts when the entry describes a wrapped line. - */ - lineOffsets: number[] -]; - -/** - * Configuration constants for the search line cache functionality. - */ -const enum Constants { - /** - * Time-to-live for cached search results in milliseconds. After this duration, cached search - * results will be invalidated to ensure they remain consistent with terminal content changes. - */ - LINES_CACHE_TIME_TO_LIVE = 15000 -} - -export class SearchLineCache extends Disposable { - /** - * translateBufferLineToStringWithWrap is a fairly expensive call. - * We memoize the calls into an array that has a time based ttl. - * _linesCache is also invalidated when the terminal cursor moves. - */ - private _linesCache: LineCacheEntry[] | undefined; - private _linesCacheTimeout = this._register(new MutableDisposable()); - private _linesCacheDisposables = this._register(new MutableDisposable()); - // Track access to avoid recreating a timeout on every init call which occurs once per search - // result (findNext/findPrevious -> _highlightAllMatches -> find loop). - private _lastAccessTimestamp = 0; - - constructor(private readonly _terminal: Terminal) { - super(); - this._register(toDisposable(() => this._destroyLinesCache())); - } - - /** - * Sets up a line cache with a ttl - */ - public initLinesCache(): void { - if (!this._linesCache) { - this._linesCache = new Array(this._terminal.buffer.active.length); - this._linesCacheDisposables.value = combinedDisposable( - this._terminal.onLineFeed(() => this._destroyLinesCache()), - this._terminal.onCursorMove(() => this._destroyLinesCache()), - this._terminal.onResize(() => this._destroyLinesCache()) - ); - } - - this._lastAccessTimestamp = Date.now(); - if (!this._linesCacheTimeout.value) { - this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE); - } - } - - private _destroyLinesCache(): void { - this._linesCache = undefined; - this._lastAccessTimestamp = 0; - this._linesCacheDisposables.clear(); - this._linesCacheTimeout.clear(); - } - - private _scheduleLinesCacheTimeout(delay: number): void { - this._linesCacheTimeout.value = disposableTimeout(() => { - if (!this._linesCache) { - return; - } - const now = Date.now(); - const elapsed = now - this._lastAccessTimestamp; - if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) { - this._destroyLinesCache(); - return; - } - this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed); - }, delay); - } - - public getLineFromCache(row: number): LineCacheEntry | undefined { - return this._linesCache?.[row]; - } - - public setLineInCache(row: number, entry: LineCacheEntry): void { - if (this._linesCache) { - this._linesCache[row] = entry; - } - } - - /** - * Translates a buffer line to a string, including subsequent lines if they are wraps. - * Wide characters will count as two columns in the resulting string. This - * function is useful for getting the actual text underneath the raw selection - * position. - * @param lineIndex The index of the line being translated. - */ - public translateBufferLineToStringWithWrap(lineIndex: number): LineCacheEntry { - const strings = []; - const lineOffsets = [0]; - let line = this._terminal.buffer.active.getLine(lineIndex); - while (line) { - const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); - const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; - let string = line.translateToString(!lineWrapsToNext); - if (lineWrapsToNext && nextLine) { - const lastCell = line.getCell(line.length - 1); - const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1; - // a wide character wrapped to the next line - if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) { - string = string.slice(0, -1); - } - } - strings.push(string); - if (lineWrapsToNext) { - lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length); - } else { - break; - } - lineIndex++; - line = nextLine; - } - return [strings.join(''), lineOffsets]; - } -} From f7f78eeddade5cc92211b1fd96ef5c4473397c10 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 29 Sep 2026 14:38:20 -0700 Subject: [PATCH 29/43] Fix bogus zero-width calls to createCellData. --- src/browser/Terminal.test.ts | 62 ++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index a98ddf8659..1aca68725b 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -440,8 +440,8 @@ describe('Terminal', () => { describe('scroll() function', () => { describe('when scrollback > 0', () => { it('should create a new line and scroll', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS + 1); @@ -451,9 +451,9 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollTop set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.scroll(DEFAULT_ATTR_DATA.clone()); @@ -463,11 +463,11 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollBottom set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); - term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0)); - term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); + term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 1)); + term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 1)); term.buffer.y = 3; term.buffer.scrollBottom = 3; term.scroll(DEFAULT_ATTR_DATA.clone()); @@ -481,11 +481,11 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); - term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0)); - term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); + term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 1)); + term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.buffer.scrollBottom = 3; @@ -506,9 +506,9 @@ describe('Terminal', () => { }); it('should create a new line and shift everything up', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line assert.equal(term.buffer.lines.length, INIT_ROWS); term.scroll(DEFAULT_ATTR_DATA.clone()); @@ -521,9 +521,9 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollTop set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.scroll(DEFAULT_ATTR_DATA.clone()); @@ -533,11 +533,11 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollBottom set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); - term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0)); - term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); + term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 1)); + term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 1)); term.buffer.y = 3; term.buffer.scrollBottom = 3; term.scroll(DEFAULT_ATTR_DATA.clone()); @@ -550,11 +550,11 @@ describe('Terminal', () => { }); it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => { - term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0)); - term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0)); - term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0)); - term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0)); - term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0)); + term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 1)); + term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 1)); + term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 1)); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.buffer.scrollBottom = 3; From c1b98251f72cdc618a860ec609d5212181cf6f7d Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 29 Sep 2026 14:42:28 -0700 Subject: [PATCH 30/43] Fix insertCells handling of final wide char This fixes a testcase with a wide char shifted right. --- src/common/buffer/BufferLine.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 8e0e60045e..bf4fe5f77e 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -806,6 +806,10 @@ export class BufferLine implements IBufferLine { this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); this.setCellFromCodepoint(pos, 0, 1, fillCellData); } + // handle fullwidth at line end: reset last cell if it is first cell of a wide char + if (this.getWidth(this.length - n - 1) === 2) { + this.setCellFromCodepoint(this.length - n - 1, 0, 1, fillCellData); + } if (n < this.length - pos) { for (let i = this.length - pos - n - 1; i >= 0; --i) { this.setCell(pos + n + i, this.loadCell(pos + i, $workCell)); @@ -818,11 +822,6 @@ export class BufferLine implements IBufferLine { this.setCell(i, fillCellData); } } - - // handle fullwidth at line end: reset last cell if it is first cell of a wide char - if (this.getWidth(this.length - 1) === 2) { - this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData); - } } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { From c58aad9c112ea8c43a3c5c70bd57186a992f67f5 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 29 Sep 2026 16:15:44 -0700 Subject: [PATCH 31/43] Optimization to _reflowLarger and remove BufferReflow.ts. Combine reflowLargerCreateNewLayout and reflowLargerApplyNewLayout into a single _reflowLargerNewLayout function. This avoids an extra pass over the lines and an extra array allocation. Also, merge in the rest of BufferReflow.ts into Buffer.ts - it was weird having some of the reflow logic in Buffer.ts and some in a separate module. --- src/common/buffer/Buffer.ts | 136 +++++++++++++++++++- src/common/buffer/BufferReflow.test.ts | 30 ----- src/common/buffer/BufferReflow.ts | 169 ------------------------- 3 files changed, 129 insertions(+), 206 deletions(-) delete mode 100644 src/common/buffer/BufferReflow.ts diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index c68561eb71..b85e89b2cc 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -9,7 +9,6 @@ import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types'; import { ICharset } from '../Types'; import { ExtendedAttrs } from './AttributeData'; import { BufferLine, LogicalLine, DEFAULT_ATTR_DATA } from './BufferLine'; -import { reflowLine, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove } from './BufferReflow'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH, Attributes } from './Constants'; import { Marker } from './Marker'; @@ -332,13 +331,103 @@ export class Buffer extends Disposable implements IBuffer { } 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._cols, newCols); if (toRemove.length > 0) { - const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); - reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); - this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved); + const countRemoved = this._reflowLargerNewLayout(this.lines, toRemove); + this._reflowLargerAdjustViewport(newCols, newRows, countRemoved); + } + } + + /** + * Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed + * when a wrapped line unwraps. + * @param oldCols The columns before resize + * @param newCols The columns after resize. + */ + private _reflowLargerGetLinesToRemove(oldCols: number, newCols: number): 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[] = []; + const lines = this.lines; + const bufferAbsoluteY = this.ybase + this.y; + // Whether to reflow the line containing the cursor. + const reflowCursorLine = this._optionsService.rawOptions.reflowCursorLine; + + 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; + } + +  /** + * 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. + */ + private _reflowLargerNewLayout(lines: CircularList, toRemove: number[]): number { + const newLayout: BufferLine[] = []; + // 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 { + // Record original lines so they don't get overridden when we rearrange the list + newLayout.push(lines.get(i) as BufferLine); + } + } + // 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. + // Now rearrange the list + for (let i = 0; i < newLayout.length; i++) { + lines.set(i, newLayout[i]); + } + lines.length = newLayout.length; + return countRemovedSoFar; } private _reflowLargerAdjustViewport(newCols: number, newRows: number, countRemoved: number): void { @@ -363,6 +452,39 @@ export class Buffer extends Disposable 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.logical(); + 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(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; // Gather all BufferLines that need to be inserted into the Buffer here so that they can be @@ -398,7 +520,7 @@ export class Buffer extends Disposable implements IBuffer { continue; } } - const newLines = reflowLine(wrappedLines, newCols); + const newLines = this._reflowLine(wrappedLines, newCols); const linesToAdd = newLines.length; let trimmedLines: number; if (this.ybase === 0 && this.y !== this.lines.length - 1) { diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index cd8a00e8b6..c8f355501b 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -7,7 +7,6 @@ import { CircularList } from '../CircularList'; import { BufferLine } from './BufferLine'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants'; -import { reflowLargerGetLinesToRemove } from './BufferReflow'; import { IBufferLine } from './Types'; /** @@ -159,33 +158,4 @@ describe('BufferReflow', () => { }); }); - describe('reflowLargerGetLinesToRemove', () => { - const nullCell = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); - - function createWrappedLines(chars: string): CircularList { - const lines = new CircularList(chars.length); - let previous = undefined; - for (let i = 0; i < chars.length; i++) { - const line = new BufferLine(1); - line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]); - if (previous) { line.setWrapped(previous); } - previous = line; - lines.push(line); - } - return lines; - } - - it('should skip reflow when the cursor is in a wrapped block and reflowCursorLine is false', () => { - const lines = createWrappedLines('abcde'); - const skipped = reflowLargerGetLinesToRemove(lines, 1, 5, 2, nullCell, false); - const reflowed = reflowLargerGetLinesToRemove(lines, 1, 5, 2, nullCell, true); - assert.deepEqual(skipped, []); - assert.notDeepEqual(reflowed, []); - }); - - it('should reflow wrapped blocks when the cursor is outside the block', () => { - const lines = createWrappedLines('abcde'); - assert.notDeepEqual(reflowLargerGetLinesToRemove(lines, 1, 5, 10, nullCell, false), []); - }); - }); }); diff --git a/src/common/buffer/BufferReflow.ts b/src/common/buffer/BufferReflow.ts deleted file mode 100644 index 808902e33b..0000000000 --- a/src/common/buffer/BufferReflow.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright (c) 2019 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { BufferLine } from './BufferLine'; -import { CircularList } from '../CircularList'; -import { IBufferLine, ICellData } from './Types'; - -export interface INewLayoutResult { - layout: number[]; - countRemoved: number; -} - -export function reflowLine(wrappedLines: BufferLine[], newCols: number): BufferLine[] { - const newLines: BufferLine[] = []; - let startCol = 0; - let curRow = 1; - let curLine = wrappedLines[0]; - const logical = curLine.logical(); - 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(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; -} - -/** - * 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; - } - } - const oldWrapped = wrappedLines.length; - 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; -} - -/** - * 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) { - 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; -} From bed9e81360c30e8d99b4cb4a063d1b3f59c71973 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 29 Sep 2026 18:13:34 -0700 Subject: [PATCH 32/43] Change isNullChar to check if previous is wide. --- src/common/buffer/BufferLine.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index bf4fe5f77e..8db1009902 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -230,7 +230,7 @@ export class LogicalLine implements ILogicalLine { /** * A null character diplays like space, but is trimmable. - * Specifically it has a zero codepoint, but a width of 1. + * Specifically it has a zero codepoint, and does not follow a wide char. */ public isNullChar(index: LogicalColumn): boolean { if (index >= this.length) { @@ -239,7 +239,8 @@ export class LogicalLine implements ILogicalLine { const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; return ((content & Content.STORED_IN_CHARS_MASK) ? (content & Content.LENGTH_IN_CHARS_MASK) === 0 - : (content & Content.CODEPOINT_MASK) === 0); + : (content & Content.CODEPOINT_MASK) === 0) + && (index === 0 || this.getWidth(index - 1) < 2); } /** Get state of protected flag. */ From 02f1239ebda5e05a10093bf1c4e5a38616f371b6 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 29 Sep 2026 18:14:15 -0700 Subject: [PATCH 33/43] Update tests for trabslateToString caching. --- src/common/buffer/BufferLine.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index f26b8d9340..85de7b63b8 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -828,14 +828,10 @@ describe('BufferLine', function(): void { assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); assert.equal(line.cachedString, 'abc'); - line.cachedString = 'cached-trimmed'; - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed'); - assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc '); - assert.equal(line.cachedString, 'abc'); - - // Any optional translation argument should bypass cache. - assert.equal(line.translateToString(false, 0, 2, undefined), 'ab'); - assert.equal(line.translateToString(true, 0, 2, undefined), 'ab'); + line.cachedString = 'ABC'; + assert.equal(line.translateToString(false, 0, 2, undefined), 'AB'); + assert.equal(line.translateToString(true, 0, 2, undefined), 'AB'); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'ABC'); }); it('should invalidate cached canonical strings on line mutations', () => { From 21686e19605c65654aba3c1efb6ea7cbe31759c5 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 1 Oct 2026 17:49:27 -0700 Subject: [PATCH 34/43] Remove old commented-out code. --- src/common/InputHandler.ts | 1 - src/common/buffer/BufferLine.ts | 19 ------------------- 2 files changed, 20 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 105d2f9413..ce40e0d9b0 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -681,7 +681,6 @@ export class InputHandler extends Disposable implements IInputHandler { data[pos] = code | (chWidth << Content.WIDTH_SHIFT); if (pendingStart < 0) pendingStart = pos; pendingCols += chWidth; - // bufferRow.setCellsFromCodepoints(this._activeBuffer.x, chWidth, data, pos, pos+1, curAttr); this._activeBuffer.x += chWidth; } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 8db1009902..0161eb9df8 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -1048,25 +1048,6 @@ export class BufferLine implements IBufferLine { newData[i3 + 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 * Constants.CELL_INDICIES; - const newIndex = (column + i) * Constants.CELL_INDICIES - 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]; - } - } - */ logicalLine.length = column + oldLogical.length; previousLine.nextBufferLine = this; for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { From 2a91169b3f03631a2097417978ed599cdc0e27b5 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Thu, 1 Oct 2026 17:50:12 -0700 Subject: [PATCH 35/43] Add some tests for LogicalLine.asString and BufferLine.startColumn. --- src/common/InputHandler.test.ts | 2 ++ src/common/buffer/Buffer.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 8a5ce3994a..11bf42890b 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -1867,6 +1867,8 @@ describe('InputHandler', () => { await inputHandler.parseP('\x1b[?45h'); await inputHandler.parseP('¥¥¥'); assert.deepEqual(getLines(bufferService, 2), ['¥¥', '¥']); + assert.equal(bufferService.buffer.lines.get(0)?.logical().asString(), '¥¥¥'); + assert.equal(bufferService.buffer.lines.get(1)?.startColumn, 4); await inputHandler.parseP(ttyBS); assert.deepEqual(getLines(bufferService, 2), ['¥¥', ' ']); assert.equal(bufferService.buffer.x, 1); diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index f32a6ee3be..c448498aaa 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -651,6 +651,8 @@ describe('Buffer', () => { // 汉语汉语汉语 assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.equal(buffer.lines.get(1)!.startColumn, 12); buffer.resize(13, 10); assert.equal(buffer.ybase, 0); assert.equal(buffer.lines.length, 10); @@ -658,11 +660,15 @@ describe('Buffer', () => { assert.equal(buffer.lines.get(0)!.translateToString(false), '汉语汉语汉语 '); assert.equal(buffer.lines.get(1)!.translateToString(true), '汉语汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(false), '汉语汉语汉语 '); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.equal(buffer.lines.get(1)!.startColumn, 12); buffer.resize(14, 10); assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语汉语汉'); assert.equal(buffer.lines.get(0)!.translateToString(false), '汉语汉语汉语汉'); assert.equal(buffer.lines.get(1)!.translateToString(true), '语汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(false), '语汉语汉语 '); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.equal(buffer.lines.get(1)!.startColumn, 14); }); it('should correctly reflow wrapped lines that end in 0 space (via tab char)', () => { buffer.fillViewportRows(); @@ -712,20 +718,32 @@ describe('Buffer', () => { // 汉语汉语汉语 assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(true), '汉语汉语汉语'); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.deepEqual([0, 1, 2, 3].map((i) => buffer.lines.get(i)!.startColumn), + [0, 12, 0, 0]); buffer.resize(11, 10); assert.equal(buffer.ybase, 0); assert.equal(buffer.lines.length, 10); assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语汉'); assert.equal(buffer.lines.get(1)!.translateToString(true), '语汉语汉语'); assert.equal(buffer.lines.get(2)!.translateToString(true), '汉语'); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.deepEqual([0, 1, 2, 3].map((i) => buffer.lines.get(i)!.startColumn), + [0, 10, 20, 0]); buffer.resize(10, 10); assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语汉'); assert.equal(buffer.lines.get(1)!.translateToString(true), '语汉语汉语'); assert.equal(buffer.lines.get(2)!.translateToString(true), '汉语'); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.deepEqual([0, 1, 2, 3].map((i) => buffer.lines.get(i)!.startColumn), + [0, 10, 20, 0]); buffer.resize(9, 10); assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(true), '汉语汉语'); assert.equal(buffer.lines.get(2)!.translateToString(true), '汉语汉语'); + assert.equal(buffer.lines.get(0)!.logical().asString(), '汉语汉语汉语汉语汉语汉语'); + assert.deepEqual([0, 1, 2, 3].map((i) => buffer.lines.get(i)!.startColumn), + [0, 8, 16, 0]); buffer.resize(8, 10); assert.equal(buffer.lines.get(0)!.translateToString(true), '汉语汉语'); assert.equal(buffer.lines.get(1)!.translateToString(true), '汉语汉语'); From f073c38e3af367cf2afb5e8a55324dca894233be Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 2 Oct 2026 11:08:36 -0700 Subject: [PATCH 36/43] Move misleadingly-named DEFAULT-ATTR to TestUtils.test.ts --- src/browser/renderer/dom/DomRendererRowFactory.test.ts | 4 ++-- src/common/TestUtils.test.ts | 5 ++++- src/common/buffer/BufferLine.test.ts | 4 ++-- src/common/buffer/Constants.ts | 2 -- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index e19f6852ac..c2648fd9fd 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,11 +6,11 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from './DomRendererRowFactory'; -import { DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; +import { FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine'; import { IBufferLine } from '../../../common/buffer/Types'; import { CellData } from '../../../common/buffer/CellData'; -import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test'; +import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA, DEFAULT_ATTR } from '../../../common/TestUtils.test'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test'; import { TestWidthCache } from './WidthCache.test'; diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index bedfa6ba1f..8961e56b6f 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -13,7 +13,10 @@ import { UnicodeV6 } from './input/UnicodeV6'; import { IDecorationOptions, IDecoration } from '@xterm/xterm'; import { Emitter, type IEvent } from './Event'; import { CellData } from './buffer/CellData'; -import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from './buffer/Constants'; +import { DEFAULT_COLOR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from './buffer/Constants'; + +// Misnamed - random value for testing. +export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); export function createCellData(attr: number, char: string, width: number): CellData { return CellData.fromCharData([attr, char, width, char.length === 0 ? 0 : char.charCodeAt(0)]); diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 85de7b63b8..457b791768 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -2,13 +2,13 @@ * Copyright (c) 2018 The xterm.js authors. All rights reserved. * @license MIT */ -import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants'; +import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants'; import { BufferLine, LogicalLine } from './BufferLine'; import { CellData } from './CellData'; import { CharData, IBufferLine, ICellData } from './Types'; import { assert } from 'chai'; import { AttributeData } from './AttributeData'; -import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test'; +import { createCellData, NULL_CELL_DATA, DEFAULT_ATTR, extendedAttributes } from '../TestUtils.test'; class TestBufferLine extends BufferLine { diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index 2b6fcf875a..572ed055e5 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -4,8 +4,6 @@ */ 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; export const CHAR_DATA_ATTR_INDEX = 0; From 00af54a4921c1487c9edd2b47ce2775fdb984413 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 2 Oct 2026 12:08:54 -0700 Subject: [PATCH 37/43] Tweaks to DomRendererRowFactory.test.ts for null handling The LogicalLine implementation does not support trimmable (final) null characters with attributes (except background), since that can never be created using escape sequences, --- src/browser/renderer/dom/DomRendererRowFactory.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index c2648fd9fd..0e6ff554c0 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,7 +6,7 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from './DomRendererRowFactory'; -import { FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; +import { FgFlags, BgFlags, Attributes, UnderlineStyle, NULL_CELL_CHAR } from '../../../common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine'; import { IBufferLine } from '../../../common/buffer/Types'; import { CellData } from '../../../common/buffer/CellData'; @@ -347,6 +347,7 @@ describe('DomRendererRowFactory', () => { }); it('should force whitespace cells to be rendered above the background', () => { lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1)); + lineData.setCell(0, createCellData(DEFAULT_ATTR, ' ', 1)); rowFactory.handleSelectionChanged([0, 0], [2, 0], false); const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1); assert.equal(extractHtml(spans), @@ -490,6 +491,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1)); lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1)); lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1)); + lineData.setCell(3, createCellData(DEFAULT_ATTR, NULL_CELL_CHAR, 1)); const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4); assert.equal(extractHtml(spans), 'aax x' From bcb7cc839d1053bb036a59bfe8af120af9cfce6e Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Mon, 5 Oct 2026 15:21:57 -0700 Subject: [PATCH 38/43] Tweaks to addCodepointToCell Add optional attrs parameter. Tweak handling of no pre-existing character in cell. --- src/common/InputHandler.ts | 2 +- src/common/buffer/BufferLine.test.ts | 2 +- src/common/buffer/BufferLine.ts | 54 +++++++++++++++------------- src/common/buffer/Constants.ts | 5 +-- src/common/buffer/Types.ts | 2 +- 5 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index ce40e0d9b0..64dfaaa8ee 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -671,7 +671,7 @@ export class InputHandler extends Disposable implements IInputHandler { // it is save to step 2 cells back here // since an empty cell is only set by fullwidth chars bufferRow.addCodepointToCell(this._activeBuffer.x - offset, - code, chWidth); + code, chWidth, curAttr); for (let delta = chWidth - oldWidth; --delta >= 0;) { bufferRow.setCellFromCodepoint(this._activeBuffer.x++, 0, 0, curAttr); } diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 457b791768..83079e7c16 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -482,7 +482,7 @@ describe('BufferLine', function(): void { describe('addCharToCell', () => { it('should set width to 1 for empty cell', () => { const line = new TestBufferLine(3, NULL_CELL_DATA, false); - line.addCodepointToCell(0, '\u0301'.charCodeAt(0), 0); + line.addCodepointToCell(0, '\u0301'.charCodeAt(0), 0, createCellData(DEFAULT_ATTR, '?', 1)); const cell = line.loadCell(0, new CellData()); // chars contains single combining char // width is set to 1 diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 0161eb9df8..012479b39e 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -367,36 +367,50 @@ export class LogicalLine implements ILogicalLine { * onto a leading char. Since we already set the attrs * by the previous `setDataFromCodePoint` call, we can omit it here. */ - public addCodepointToCell(column: LogicalColumn, codePoint: number, width: number): void { + public addCodepointToCell(column: LogicalColumn, codePoint: number, width: number, attrs?: IAttributeData): void { const dindex = this._dataStart + column * Constants.CELL_INDICIES; - let content = this._data[dindex + Cell.CONTENT]; + let content = column >= this.length ? 0 : this._data[dindex + Cell.CONTENT]; const oldWidth = (content & Content.WIDTH_MASK) >> Content.WIDTH_SHIFT; + width = Math.max(width, 1); if (width > oldWidth) { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; + if (column + width > this.length) { + this.resizeData(column + width); + this.length = column + width; + } } - content |= Content.IS_COMBINED_MASK; const addedStr = stringFromCodePoint(codePoint); const addedLength = addedStr.length; let oldCellStr; const oldStrLen = this._chars.length; - if (content & Content.STORED_IN_CHARS_MASK) { - const oldStart = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; - const oldLength = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; - if (oldStart + oldLength === oldStrLen) { - // append at end - can reuse old combined data. - // Preserve value of _charsIsTextValue - this._chars += addedStr; - this._data[dindex + Cell.CONTENT] = encodeRange(content, oldStart, oldLength + addedLength); - return; + if (content & Content.HAS_CONTENT_MASK) { + content |= Content.IS_COMBINED_MASK; + if (content & Content.STORED_IN_CHARS_MASK) { + const oldStart = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; + const oldLength = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; + if (oldStart + oldLength === oldStrLen) { + // append at end - can reuse old combined data. + // Preserve value of _charsIsTextValue + this._chars += addedStr; + this._data[dindex + Cell.CONTENT] = encodeRange(content, oldStart, oldLength + addedLength); + return; + } + oldCellStr = this._chars.substring(oldStart, oldStart + oldLength); + } else { + oldCellStr = stringFromCodePoint(content & Content.CODEPOINT_MASK); } - oldCellStr = this._chars.substring(oldStart, oldStart + oldLength); } else { - oldCellStr = stringFromCodePoint(content & Content.CODEPOINT_MASK); + oldCellStr = ''; } this._charsIsTextValue = false; this._chars += oldCellStr + addedStr; this._data[dindex + Cell.CONTENT] = encodeRange(content, oldStrLen, oldCellStr.length + addedLength); + if (attrs) { + this._data[dindex + Cell.FG] = attrs.fg; + this._data[dindex + Cell.BG] = attrs.bg; + if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[column] = attrs.extended; } + } } /** @@ -787,16 +801,8 @@ export class BufferLine implements IBufferLine { * onto a leading char. Since we already set the attrs * by the previous `setDataFromCodePoint` call, we can omit it here. */ - public addCodepointToCell(index: number, codePoint: number, width: number): void { - 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; - } - lline.addCodepointToCell(lcolumn, codePoint, width); + public addCodepointToCell(index: number, codePoint: number, width: number, attrs?: IAttributeData): void { + this._logicalLine.addCodepointToCell(index + this.startColumn, codePoint, width, attrs); } public insertCells(pos: number, n: number, fillCellData: ICellData): void { diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index 572ed055e5..87f03eabe1 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -62,10 +62,11 @@ export const enum Content { /** * bit 1..22 mask to check whether a cell contains any string data * we need to check for codepoint and isCombined bits to see - * whether a cell contains anything + * whether a cell contains anything. + * We also check LENGTH_IN_CHARS_MASK in case STORED_IN_CHARS_MASK. * read: `isEmpty = !(content & Content.HAS_CONTENT_MASK)` */ - HAS_CONTENT_MASK = 0x3FFFFF, + HAS_CONTENT_MASK = 0x0F3FFFFF, /** * bit 23..24 wcwidth value of cell, takes 2 bits (ranges from 0..2) diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index b111e2d798..648ac3b048 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -148,7 +148,7 @@ export interface IBufferLine { setCell(index: number, cell: ICellData): void; setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void; setCellsFromCodepoints(index: number, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData): void; - addCodepointToCell(index: number, codePoint: number, width: number): void; + addCodepointToCell(index: number, codePoint: number, width: number, attrs?: IAttributeData): void; insertCells(pos: number, n: number, ch: ICellData): void; deleteCells(pos: number, n: number, fill: ICellData): void; replaceCells(start: number, end: number, fill: ICellData, respectProtect?: boolean): void; From b5c20007b1e87f82f371ad7b8767a235c016d285 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 6 Oct 2026 18:04:18 -0700 Subject: [PATCH 39/43] Fix translateToStrng if start column > validEnd Fixes last failing unit test. --- src/common/buffer/BufferLine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 012479b39e..296c965e2a 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -1119,7 +1119,7 @@ export class BufferLine implements IBufferLine { } else { validEnd = this._logicalLine.length; } - validEnd = Math.min(endCol, validEnd); + validEnd = Math.max(startCol, Math.min(endCol, validEnd)); let result = lline.asString(startCol, validEnd); const paddingNeeded = trimRight ? 0 : Math.max(0, endCol - validEnd); if (paddingNeeded) { From 7c8f52ecc1a0ea96e17bb295b67916fd333733e5 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 6 Oct 2026 18:25:42 -0700 Subject: [PATCH 40/43] Fix some lint complaints --- src/common/buffer/Buffer.ts | 2 +- src/common/buffer/BufferLine.ts | 47 +-------------------------------- 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index b85e89b2cc..32d284eca8 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -391,7 +391,7 @@ export class Buffer extends Disposable implements IBuffer { return toRemove; } -  /** + /** * 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. diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 296c965e2a..fb049a1131 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -6,7 +6,7 @@ import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs, BufferColumn, LogicalColumn } from './Types'; import { AttributeData } from './AttributeData'; import { CellData, encodeRange } from './CellData'; -import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; +import { Attributes, BgFlags, Content, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; import { stringFromCodePoint } from '../input/TextDecoder'; // Buffer memory layout: @@ -457,51 +457,6 @@ export class LogicalLine implements ILogicalLine { } } - /* * - * 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; - } - const cellContents: string[] = []; - while (startCol < endCol) { - const chars = this.getString(startCol); - / * - const content = startCol >= dataLength ? 0 - : this._data[this._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; - const cp = content & Content.CODEPOINT_MASK; - const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; - cellContents.push(chars); - if (outColumns) { - for (let i = 0; i < chars.length; ++i) { - outColumns.push(startCol); - } - } - startCol++; - // startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 - * / - } - if (outColumns) { - outColumns.push(startCol); - } - const result = cellContents.join(''); - return result; - } - */ - public asString(startCol: LogicalColumn = 0, endCol: LogicalColumn = -1): string { if (!this._charsIsTextValue) { const cellContents: string[] = []; From bf0c908c91e912f251fe63ce570743a423220b9d Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Wed, 7 Oct 2026 10:18:09 -0700 Subject: [PATCH 41/43] Rename LogicalLine length property to trimmedLength. This reduces risk of confusion with BufferLine length property. --- src/common/buffer/Buffer.ts | 2 +- src/common/buffer/BufferLine.ts | 94 ++++++++++++++-------------- src/common/services/BufferService.ts | 8 +-- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 32d284eca8..bb6a487a74 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -460,7 +460,7 @@ export class Buffer extends Disposable implements IBuffer { const logical = curLine.logical(); for (;;) { const endCol = logical.charStart(startCol + newCols); - if (endCol >= logical.length) { + if (endCol >= logical.trimmedLength) { curLine.nextBufferLine = undefined; curLine.startColumn = startCol; break; diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index fb049a1131..20e44fb4a4 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -83,7 +83,7 @@ export class LogicalLine implements ILogicalLine { * Logical "trimmed" length of line. * Must be no more than this._dataLength / 3. */ - public length: number = 0; + public trimmedLength: number = 0; /** If _chars is the text value of this line. */ public _charsIsTextValue: boolean = true; @@ -96,7 +96,7 @@ export class LogicalLine implements ILogicalLine { this._data = data; this._dataStart = start; this._dataLength = dlength; - this.length = 0; + this.trimmedLength = 0; this._extendedAttrs = {}; } public forEachBufferLine(callback: (line: IBufferLine) => void): void { @@ -116,7 +116,7 @@ export class LogicalLine implements ILogicalLine { const newLength = Math.max(uint32Cells + 60, allocateBigBlock ? allocateBigBlock * 3 : (3 * oldLength) >> 1); const data = new Uint32Array(newLength); - for (let i = 3 * this.length; --i >= 0; ) { + for (let i = 3 * this.trimmedLength; --i >= 0; ) { data[i] = this._data[this._dataStart + i]; } this._data = data; @@ -127,19 +127,19 @@ export class LogicalLine implements ILogicalLine { public showContents(): string { let result = '['; - for (let i = 0; i < this.length; i++) { + for (let i = 0; i < this.trimmedLength; i++) { if (i > 0) { result += '; '; } result += `#${i}:`; result += this._data[i * Constants.CELL_INDICIES + this._dataStart].toString(16); const s = this.getString(i); if (s.length > 0) { result += `=${JSON.stringify(s)}`; } } - result += ']len:'+this.length; + result += ']len:'+this.trimmedLength; return result; } public getWidth(index: LogicalColumn): number { - return index >= this.length ? NULL_CELL_WIDTH + return index >= this.trimmedLength ? NULL_CELL_WIDTH : this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; } @@ -148,7 +148,7 @@ export class LogicalLine implements ILogicalLine { * @internal */ public charStart(column: LogicalColumn): number { - return column > this.length ? this.length + return column > this.trimmedLength ? this.trimmedLength : column > 0 && this.getWidth(column - 1) > 1 ? column - 1 : column; } @@ -157,7 +157,7 @@ export class LogicalLine implements ILogicalLine { * Load data at `index` into `cell`. */ public loadCell(index: LogicalColumn, cell: ICellData): ICellData { - if (index >= this.length) { + if (index >= this.trimmedLength) { cell.content = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; cell.fg = 0; cell.bg = this.backgroundColor; @@ -186,8 +186,8 @@ export class LogicalLine implements ILogicalLine { return cell; } - public getExtended(index: LogicalColumn, validEnd: LogicalColumn = this.length): IExtendedAttrs | undefined { - return index < this.length + public getExtended(index: LogicalColumn, validEnd: LogicalColumn = this.trimmedLength): IExtendedAttrs | undefined { + return index < this.trimmedLength && (this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) ? this._extendedAttrs[index] : undefined; @@ -195,7 +195,7 @@ export class LogicalLine implements ILogicalLine { /** Returns the string content of the cell. */ public getString(index: number): string { - if (index >= this.length) { + if (index >= this.trimmedLength) { return ''; } const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; @@ -233,7 +233,7 @@ export class LogicalLine implements ILogicalLine { * Specifically it has a zero codepoint, and does not follow a wide char. */ public isNullChar(index: LogicalColumn): boolean { - if (index >= this.length) { + if (index >= this.trimmedLength) { return true; } const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; @@ -279,8 +279,8 @@ export class LogicalLine implements ILogicalLine { this._charsIsTextValue = false; const isNull = codePoint === 0 && width === 1 && attrs.bg === this.backgroundColor; - if (isNull && index >= this.length - 1 ) { - if (index === this.length - 1) { + if (isNull && index >= this.trimmedLength - 1 ) { + if (index === this.trimmedLength - 1) { // FIXME should also truncate extendedAttrs // remove any now-trailing nulls while (index > 0) { @@ -293,24 +293,24 @@ export class LogicalLine implements ILogicalLine { } index--; } - this.length = index; // this.length - 1; + this.trimmedLength = index; // this.length - 1; this.trimLength(); } return; } - if (index + width > this.length && !isNull) { + if (index + width > this.trimmedLength && !isNull) { this.resizeData(index + width); - let j = this._dataStart + this.length * Constants.CELL_INDICIES; + let j = this._dataStart + this.trimmedLength * Constants.CELL_INDICIES; // If wide, we need to clean index+1 in addition. We don't need // to clear index (as it get overwritten) but this is simple. const lastToClear = width <= 1 ? index - 1 : index + 1; - for (let i = this.length; i <= lastToClear; i++) { + for (let i = this.trimmedLength; i <= lastToClear; i++) { this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; this._data[j + Cell.FG] = 0; this._data[j + Cell.BG] = this.backgroundColor; j += Constants.CELL_INDICIES; } - this.length = index + width; + this.trimmedLength = index + width; } if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; @@ -323,15 +323,15 @@ export class LogicalLine implements ILogicalLine { public setCellsFromCodepoints(index: LogicalColumn, cols: number, codePoints: Uint32Array, start: number, end: number, attrs: IAttributeData, allocateBigBlock: number = 0): void { this._charsIsTextValue = false; - if (index + cols >= this.length) { + if (index + cols >= this.trimmedLength) { this.resizeData(index + cols, allocateBigBlock); - for (let i = this.length; i < index; i++) { + for (let i = this.trimmedLength; i < index; i++) { const j = this._dataStart + i * Constants.CELL_INDICIES; this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; this._data[j + Cell.FG] = 0; this._data[j + Cell.BG] = this.backgroundColor; } - this.length = index + cols; + this.trimmedLength = index + cols; } let oldContent = -1; const data = this._data; @@ -356,7 +356,7 @@ export class LogicalLine implements ILogicalLine { j += 3; index++; } } - if ((oldContent >>> Content.WIDTH_SHIFT) > 1 && index < this.length) { + if ((oldContent >>> Content.WIDTH_SHIFT) > 1 && index < this.trimmedLength) { data[j + Cell.CONTENT] = NULL_CELL_CODE | (NULL_CELL_WIDTH << Content.WIDTH_SHIFT); } } @@ -369,15 +369,15 @@ export class LogicalLine implements ILogicalLine { */ public addCodepointToCell(column: LogicalColumn, codePoint: number, width: number, attrs?: IAttributeData): void { const dindex = this._dataStart + column * Constants.CELL_INDICIES; - let content = column >= this.length ? 0 : this._data[dindex + Cell.CONTENT]; + let content = column >= this.trimmedLength ? 0 : this._data[dindex + Cell.CONTENT]; const oldWidth = (content & Content.WIDTH_MASK) >> Content.WIDTH_SHIFT; width = Math.max(width, 1); if (width > oldWidth) { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; - if (column + width > this.length) { + if (column + width > this.trimmedLength) { this.resizeData(column + width); - this.length = column + width; + this.trimmedLength = column + width; } } const addedStr = stringFromCodePoint(codePoint); @@ -425,7 +425,7 @@ export class LogicalLine implements ILogicalLine { */ public trimLength(): void { this._charsIsTextValue = false; - let index = this.length; + let index = this.trimmedLength; while (index > 0) { index--; const j = this._dataStart + index * Constants.CELL_INDICIES; @@ -435,8 +435,8 @@ export class LogicalLine implements ILogicalLine { break; } } - if (index < this.length) { - this.length = index; + if (index < this.trimmedLength) { + this.trimmedLength = index; for (let line = this.firstBufferLine; line; line = line.nextBufferLine) { if (line.startColumn > index) { line.startColumn = index; @@ -460,7 +460,7 @@ export class LogicalLine implements ILogicalLine { public asString(startCol: LogicalColumn = 0, endCol: LogicalColumn = -1): string { if (!this._charsIsTextValue) { const cellContents: string[] = []; - const llen = this.length; + const llen = this.trimmedLength; let nchars = 0; let j = this._dataStart + Cell.CONTENT; let prevWasWide = false; @@ -485,7 +485,7 @@ export class LogicalLine implements ILogicalLine { this._chars = result; } if (startCol >= 0 && endCol >= 0) { - if (startCol >= this.length || endCol <= startCol) { return ''; } + if (startCol >= this.trimmedLength || endCol <= startCol) { return ''; } const startContent = this._data[this._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const start = (startContent & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const lastContent = this._data[this._dataStart + (endCol - 1) * Constants.CELL_INDICIES + Cell.CONTENT]; @@ -501,7 +501,7 @@ export class LogicalLine implements ILogicalLine { public offsetInString(index: LogicalColumn): number { if (!this._charsIsTextValue) { this.asString(); } - if (index >= this.length) { + if (index >= this.trimmedLength) { return this._chars.length; } const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; @@ -548,7 +548,7 @@ 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.trimmedLength; } constructor(cols: number, @@ -627,7 +627,7 @@ export class BufferLine implements IBufferLine { public getBg(index: number): number { index += this.startColumn; const lline = this._logicalLine; - return index > lline.length ? lline.backgroundColor + return index > lline.trimmedLength ? lline.backgroundColor : lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.BG]; } @@ -683,7 +683,7 @@ export class BufferLine implements IBufferLine { public isProtected(index: number): number { const lline = this._logicalLine; const lcolumn = index + this.startColumn; - return index >= this.length || lcolumn >= lline.length ? 0 + return index >= this.length || lcolumn >= lline.trimmedLength ? 0 : lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } @@ -865,7 +865,7 @@ export class BufferLine implements IBufferLine { const uint32Cells = cols * Constants.CELL_INDICIES; if (cols > this.length) { logical.resizeData(cols); - logical.length = cols; + logical.trimmedLength = cols; for (let i = this.length; i < cols; ++i) { this.setCell(i, fillCellData); } @@ -984,12 +984,12 @@ export class BufferLine implements IBufferLine { if (!next) break; next.startColumn -= count; } - lline.copyCellsFrom(lline, oldEnd, lineEnd, lline.length - oldEnd, false); - lline.length -= count; + lline.copyCellsFrom(lline, oldEnd, lineEnd, lline.trimmedLength - oldEnd, false); + lline.trimmedLength -= count; } } else { - if (lineEnd < lline.length) { - lline.length = lineEnd; + if (lineEnd < lline.trimmedLength) { + lline.trimmedLength = lineEnd; } } } @@ -999,17 +999,17 @@ export class BufferLine implements IBufferLine { const column = previousLine.startColumn + previousLine.length; const logicalLine = previousLine._logicalLine; const oldLogical = this._logicalLine; - logicalLine.resizeData(column + oldLogical.length); + logicalLine.resizeData(column + oldLogical.trimmedLength); const newData = logicalLine._data; const dataStart = logicalLine._dataStart; - for (let i = logicalLine.length; i < column + oldLogical.length; i++) { + for (let i = logicalLine.trimmedLength; i < column + oldLogical.trimmedLength; i++) { const i3 = dataStart + i * Constants.CELL_INDICIES; newData[i3 + Cell.CONTENT] = 0; newData[i3 + Cell.FG] = 0; newData[i3 + Cell.BG] = logicalLine.backgroundColor; } - logicalLine.copyCellsFrom(oldLogical, 0, column, oldLogical.length, false); - logicalLine.length = column + oldLogical.length; + logicalLine.copyCellsFrom(oldLogical, 0, column, oldLogical.trimmedLength, false); + logicalLine.trimmedLength = column + oldLogical.trimmedLength; previousLine.nextBufferLine = this; for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { line.startColumn += column; @@ -1026,7 +1026,7 @@ export class BufferLine implements IBufferLine { const oldLine = prevRow._logicalLine; const cell = new CellData(); this.loadCell(oldStartColumn, cell); - const newLength = oldLine.length - oldStartColumn; + const newLength = oldLine.trimmedLength - oldStartColumn; const newLogical = new LogicalLine(newLength); newLogical.copyCellsFrom(oldLine, oldStartColumn, 0, newLength, false); newLogical.firstBufferLine = this; @@ -1034,7 +1034,7 @@ export class BufferLine implements IBufferLine { nextRow.startColumn -= oldStartColumn; nextRow._logicalLine = newLogical; } - oldLine.length = oldStartColumn; + oldLine.trimmedLength = oldStartColumn; oldLine.trimLength(); // FIXME truncate/resize newLogical.backgroundColor = oldLine.backgroundColor; @@ -1072,7 +1072,7 @@ export class BufferLine implements IBufferLine { validEnd--; } } else { - validEnd = this._logicalLine.length; + validEnd = this._logicalLine.trimmedLength; } validEnd = Math.max(startCol, Math.min(endCol, validEnd)); let result = lline.asString(startCol, validEnd); diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 33e49370dd..0210bf6381 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -88,9 +88,9 @@ export class BufferService extends Disposable implements IBufferService { } else if (buffer.allocateBigBlock() > 0) { // In this case we try to use a large block for many lines. // Grab the rest of the block - const newStart = lline._dataStart + 3 * lline.length; - const newLength = lline._dataLength - 3 * lline.length; - lline._dataLength = lline.length; + const newStart = lline._dataStart + 3 * lline.trimmedLength; + const newLength = lline._dataLength - 3 * lline.trimmedLength; + lline._dataLength = lline.trimmedLength; // Perhaps should also re-use recycledLine.logical(). // However, that seems to be slightly slower - unclear why. lline = new LogicalLine(0, dbuffer, newStart, newLength); @@ -106,7 +106,7 @@ export class BufferService extends Disposable implements IBufferService { } if (isWrapped && oldLine) { oldLine.nextBufferLine = newLine; - newLine.startColumn = lline.length; + newLine.startColumn = lline.trimmedLength; } lline.backgroundColor = eraseAttr.bg & Attributes.COLOR_MASK; From 38e5b689da3f3f2b38fab5927bcd2095278ddf78 Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Wed, 7 Oct 2026 11:45:51 -0700 Subject: [PATCH 42/43] Update serialize test for length -> trimmedLength rename. --- 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 50c1c92c6c..f93094d4e5 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.length * 3); + return new Uint32Array(value.buffer, 0, logical.trimmedLength * 3); } return value })); From 2a89ceb2cbf693066e5652fd5822fa52ee58c21c Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Fri, 9 Oct 2026 09:56:28 -0700 Subject: [PATCH 43/43] For LogicalLine._data use Uint32Array byte-offsets This lets us get rid of _dataStart and _dataLength fields. This is a modifcation of Aug 28 change. The downside is allocating Uint32Arrays more frewuently (especially when scrolling) as opposed to re-using the same array with modified offsets. Doesn't seem to be measurably different, and this change makes the code simpler. --- src/common/buffer/BufferLine.ts | 85 ++++++++++++---------------- src/common/services/BufferService.ts | 12 ++-- 2 files changed, 42 insertions(+), 55 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 20e44fb4a4..aed57143bf 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -61,8 +61,6 @@ export class LogicalLine implements ILogicalLine { * @internal */ public _data: Uint32Array; - public _dataStart: number = 0; - public _dataLength: number = 0; /** * If charsIsTextValue is true: The text value of the line. @@ -87,18 +85,10 @@ export class LogicalLine implements ILogicalLine { /** If _chars is the text value of this line. */ public _charsIsTextValue: boolean = true; - constructor(cols: number = 0, data: Uint32Array = cols === 0 ? EMPTY_DATA : new Uint32Array(cols * Constants.CELL_INDICIES), start: number = 0, dlength: number = data.length - start) { + constructor(cols: number = 0, data: Uint32Array = cols === 0 ? EMPTY_DATA : new Uint32Array(cols * Constants.CELL_INDICIES)) { this._data = data; - this._dataStart = start; - this._dataLength = dlength; - } - public setData(data: Uint32Array, start: number, dlength: number): void { - this._data = data; - this._dataStart = start; - this._dataLength = dlength; - this.trimmedLength = 0; - this._extendedAttrs = {}; } + public forEachBufferLine(callback: (line: IBufferLine) => void): void { for (let line = this.firstBufferLine; line; line = line.nextBufferLine) { callback(line); @@ -110,18 +100,16 @@ export class LogicalLine implements ILogicalLine { */ public resizeData(cols: number, allocateBigBlock: number = 0): void { const uint32Cells = cols * Constants.CELL_INDICIES; - const oldLength = this._dataLength; + const oldLength = this._data.length; if (uint32Cells >= oldLength) { // increase by at least 50% const newLength = Math.max(uint32Cells + 60, allocateBigBlock ? allocateBigBlock * 3 : (3 * oldLength) >> 1); const data = new Uint32Array(newLength); for (let i = 3 * this.trimmedLength; --i >= 0; ) { - data[i] = this._data[this._dataStart + i]; + data[i] = this._data[i]; } this._data = data; - this._dataStart = 0; - this._dataLength = newLength; } } @@ -130,7 +118,7 @@ export class LogicalLine implements ILogicalLine { for (let i = 0; i < this.trimmedLength; i++) { if (i > 0) { result += '; '; } result += `#${i}:`; - result += this._data[i * Constants.CELL_INDICIES + this._dataStart].toString(16); + result += this._data[i * Constants.CELL_INDICIES ].toString(16); const s = this.getString(i); if (s.length > 0) { result += `=${JSON.stringify(s)}`; } } @@ -140,7 +128,7 @@ export class LogicalLine implements ILogicalLine { public getWidth(index: LogicalColumn): number { return index >= this.trimmedLength ? NULL_CELL_WIDTH - : this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; + : this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; } /** @@ -163,7 +151,7 @@ export class LogicalLine implements ILogicalLine { cell.bg = this.backgroundColor; return cell; } - const startIndex = this._dataStart + index * Constants.CELL_INDICIES; + const startIndex = index * Constants.CELL_INDICIES; const content = this._data[startIndex + Cell.CONTENT]; (cell as CellData)._string = content & Content.STORED_IN_CHARS_MASK ? this._chars : ''; @@ -188,7 +176,7 @@ export class LogicalLine implements ILogicalLine { public getExtended(index: LogicalColumn, validEnd: LogicalColumn = this.trimmedLength): IExtendedAttrs | undefined { return index < this.trimmedLength - && (this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) + && (this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) ? this._extendedAttrs[index] : undefined; } @@ -198,7 +186,7 @@ export class LogicalLine implements ILogicalLine { if (index >= this.trimmedLength) { return ''; } - const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; @@ -217,7 +205,7 @@ export class LogicalLine implements ILogicalLine { * a single UTF32 codepoint or the last codepoint of a combined string. */ public getCodePoint(index: LogicalColumn): number { - const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.STORED_IN_CHARS_MASK) { const start = (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; const length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; @@ -236,7 +224,7 @@ export class LogicalLine implements ILogicalLine { if (index >= this.trimmedLength) { return true; } - const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; return ((content & Content.STORED_IN_CHARS_MASK) ? (content & Content.LENGTH_IN_CHARS_MASK) === 0 : (content & Content.CODEPOINT_MASK) === 0) @@ -245,7 +233,7 @@ export class LogicalLine implements ILogicalLine { /** Get state of protected flag. */ public isProtected(index: number): number { - return this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; + return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } public setCell(index: LogicalColumn, cell: ICellData): void { @@ -262,7 +250,7 @@ export class LogicalLine implements ILogicalLine { } else { content = cell.getCode() | (width << Content.WIDTH_SHIFT); } - this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] = content; + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content; return; } content = content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); @@ -284,7 +272,7 @@ export class LogicalLine implements ILogicalLine { // FIXME should also truncate extendedAttrs // remove any now-trailing nulls while (index > 0) { - const j = this._dataStart + (index - 1) * Constants.CELL_INDICIES; + const j = (index - 1) * Constants.CELL_INDICIES; const content = this._data[j + Cell.CONTENT]; if ((content & Content.HAS_CONTENT_MASK) || (content & Content.WIDTH_MASK) !== 0 @@ -300,7 +288,7 @@ export class LogicalLine implements ILogicalLine { } if (index + width > this.trimmedLength && !isNull) { this.resizeData(index + width); - let j = this._dataStart + this.trimmedLength * Constants.CELL_INDICIES; + let j = this.trimmedLength * Constants.CELL_INDICIES; // If wide, we need to clean index+1 in addition. We don't need // to clear index (as it get overwritten) but this is simple. const lastToClear = width <= 1 ? index - 1 : index + 1; @@ -315,7 +303,7 @@ export class LogicalLine implements ILogicalLine { if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } - const j = this._dataStart + index * Constants.CELL_INDICIES; + const j = index * Constants.CELL_INDICIES; this._data[j + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); this._data[j + Cell.FG] = attrs.fg; this._data[j + Cell.BG] = attrs.bg; @@ -326,7 +314,7 @@ export class LogicalLine implements ILogicalLine { if (index + cols >= this.trimmedLength) { this.resizeData(index + cols, allocateBigBlock); for (let i = this.trimmedLength; i < index; i++) { - const j = this._dataStart + i * Constants.CELL_INDICIES; + const j = i * Constants.CELL_INDICIES; this._data[j + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; this._data[j + Cell.FG] = 0; this._data[j + Cell.BG] = this.backgroundColor; @@ -338,7 +326,7 @@ export class LogicalLine implements ILogicalLine { const fg = attrs.fg; const bg = attrs.bg; const ext = (attrs.bg & BgFlags.HAS_EXTENDED) ? attrs.extended : undefined; - let j = this._dataStart + index * Constants.CELL_INDICIES; + let j = index * Constants.CELL_INDICIES; for (let i = start; i < end; i++) { const contents = codePoints[i]; let width = (contents >>> Content.WIDTH_SHIFT); @@ -368,7 +356,7 @@ export class LogicalLine implements ILogicalLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(column: LogicalColumn, codePoint: number, width: number, attrs?: IAttributeData): void { - const dindex = this._dataStart + column * Constants.CELL_INDICIES; + const dindex = column * Constants.CELL_INDICIES; let content = column >= this.trimmedLength ? 0 : this._data[dindex + Cell.CONTENT]; const oldWidth = (content & Content.WIDTH_MASK) >> Content.WIDTH_SHIFT; width = Math.max(width, 1); @@ -428,7 +416,7 @@ export class LogicalLine implements ILogicalLine { let index = this.trimmedLength; while (index > 0) { index--; - const j = this._dataStart + index * Constants.CELL_INDICIES; + const j = index * Constants.CELL_INDICIES; const content = this._data[j + Cell.CONTENT]; if (content & Content.HAS_CONTENT_MASK) { index++; @@ -462,7 +450,7 @@ export class LogicalLine implements ILogicalLine { const cellContents: string[] = []; const llen = this.trimmedLength; let nchars = 0; - let j = this._dataStart + Cell.CONTENT; + let j = Cell.CONTENT; let prevWasWide = false; for (let i = 0; i < llen; i++) { const cstr = this.getString(i); @@ -486,9 +474,9 @@ export class LogicalLine implements ILogicalLine { } if (startCol >= 0 && endCol >= 0) { if (startCol >= this.trimmedLength || endCol <= startCol) { return ''; } - const startContent = this._data[this._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; + const startContent = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const start = (startContent & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; - const lastContent = this._data[this._dataStart + (endCol - 1) * Constants.CELL_INDICIES + Cell.CONTENT]; + const lastContent = this._data[(endCol - 1) * Constants.CELL_INDICIES + Cell.CONTENT]; const lastStart = (lastContent & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; let lastLength = (lastContent & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; if (lastLength === 0 && (lastContent >>> Content.WIDTH_SHIFT) > 0) { @@ -504,7 +492,7 @@ export class LogicalLine implements ILogicalLine { if (index >= this.trimmedLength) { return this._chars.length; } - const content = this._data[this._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; return (content & Content.START_IN_CHARS_MASK) >>> Content.START_IN_CHARS_SHIFT; } } @@ -580,11 +568,11 @@ export class BufferLine implements IBufferLine { if (lindex >= this.validEnd) { return [0, '', NULL_CELL_WIDTH, 0]; } - const content = lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const str = lline.getString(lindex); return [ - lline._data[lline._dataStart + lindex * Constants.CELL_INDICIES + Cell.FG], + lline._data[lindex * Constants.CELL_INDICIES + Cell.FG], str, content >> Content.WIDTH_SHIFT, (content & Content.IS_COMBINED_MASK) @@ -620,7 +608,7 @@ export class BufferLine implements IBufferLine { public getFg(index: number): number { const lline = this._logicalLine; const lcolumn = index + this.startColumn; - return lcolumn >= this.validEnd ? 0 : lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.FG]; + return lcolumn >= this.validEnd ? 0 : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.FG]; } /** Get BG cell component. */ @@ -628,7 +616,7 @@ export class BufferLine implements IBufferLine { index += this.startColumn; const lline = this._logicalLine; return index > lline.trimmedLength ? lline.backgroundColor - : lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.BG]; + : lline._data[index * Constants.CELL_INDICIES + Cell.BG]; } /** @@ -642,7 +630,7 @@ export class BufferLine implements IBufferLine { return 0; } const lline = this._logicalLine; - return lline._data[lline._dataStart + index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; + return lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** @@ -666,7 +654,7 @@ export class BufferLine implements IBufferLine { if (lcolumn >= this.validEnd) { return 0; } - return lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; + return lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ @@ -684,7 +672,7 @@ export class BufferLine implements IBufferLine { const lline = this._logicalLine; const lcolumn = index + this.startColumn; return index >= this.length || lcolumn >= lline.trimmedLength ? 0 - : lline._data[lline._dataStart + lcolumn * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; + : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } /** @@ -860,7 +848,7 @@ export class BufferLine implements IBufferLine { throw new Error('invalid call to resize'); } if (cols === this.length) { - return logical._dataLength * 4 * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; + return logical._data.length * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } const uint32Cells = cols * Constants.CELL_INDICIES; if (cols > this.length) { @@ -872,8 +860,6 @@ export class BufferLine implements IBufferLine { } else { // optimization: just shrink the view on existing buffer logical._data = logical._data.subarray(0, cols * Constants.CELL_INDICIES); - logical._dataStart = 0; - logical._dataLength = cols * Constants.CELL_INDICIES; // remove any cut off extended attributes const extKeys = Object.keys(logical._extendedAttrs); for (let i = 0; i < extKeys.length; i++) { @@ -938,7 +924,7 @@ export class BufferLine implements IBufferLine { const startColumn = this.startColumn; const data = logicalLine._data; for (let i = this.validEnd; --i >= startColumn; ) { - const j = logicalLine._dataStart + i * Constants.CELL_INDICIES; + const j = i * Constants.CELL_INDICIES; if (!logicalLine.isNullChar(i) || ((data[j + Cell.BG] & Attributes.CM_MASK))) { i += data[j + Cell.CONTENT] >> Content.WIDTH_SHIFT; @@ -1001,9 +987,8 @@ export class BufferLine implements IBufferLine { const oldLogical = this._logicalLine; logicalLine.resizeData(column + oldLogical.trimmedLength); const newData = logicalLine._data; - const dataStart = logicalLine._dataStart; for (let i = logicalLine.trimmedLength; i < column + oldLogical.trimmedLength; i++) { - const i3 = dataStart + i * Constants.CELL_INDICIES; + const i3 = i * Constants.CELL_INDICIES; newData[i3 + Cell.CONTENT] = 0; newData[i3 + Cell.FG] = 0; newData[i3 + Cell.BG] = logicalLine.backgroundColor; @@ -1083,7 +1068,7 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.length = 0; while (startCol < validEnd) { - const content = lline._data[lline._dataStart + startCol * Constants.CELL_INDICIES + Cell.CONTENT]; + const content = lline._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; let length = (content & Content.LENGTH_IN_CHARS_MASK) >>> Content.LENGTH_IN_CHARS_SHIFT; const width = content >> Content.WIDTH_SHIFT; if (length === 0) { length = width; } diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 0210bf6381..10e59d2227 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -81,19 +81,21 @@ export class BufferService extends Disposable implements IBufferService { const willBufferBeTrimmed = buffer.lines.isFull; let lline: LogicalLine = oldLine.logical(); - const dbuffer = lline._data; const recycledLine = buffer.scrollTop === 0 && atBottom && willBufferBeTrimmed && buffer.lines.recycle() as BufferLine; if (isWrapped) { } else if (buffer.allocateBigBlock() > 0) { // In this case we try to use a large block for many lines. // Grab the rest of the block - const newStart = lline._dataStart + 3 * lline.trimmedLength; - const newLength = lline._dataLength - 3 * lline.trimmedLength; - lline._dataLength = lline.trimmedLength; + const data = lline._data; + const dbuffer = data.buffer; + const newStart = data.byteOffset + 3 * 4 * lline.trimmedLength; + const newLength = data.length - 3 * lline.trimmedLength; + lline._data = new Uint32Array(dbuffer, data.byteOffset, 3 * lline.trimmedLength); + const nbuffer = new Uint32Array(dbuffer, newStart, newLength); // Perhaps should also re-use recycledLine.logical(). // However, that seems to be slightly slower - unclear why. - lline = new LogicalLine(0, dbuffer, newStart, newLength); + lline = new LogicalLine(0, nbuffer); } else { lline = new LogicalLine(this.cols); }