diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 8c89a16b08..e22c247533 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -5,7 +5,10 @@ import { IDisposable } from '@xterm/xterm'; import { ImageRenderer } from './ImageRenderer'; -import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types'; +import type { ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, ImageLayer } from './Types'; +import type { IBufferLine } from 'common/Types'; +import { CellData } from 'common/buffer/CellData'; + // fallback default cell size @@ -14,93 +17,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = { height: 14 }; -/** - * Extend extended attribute to also hold image tile information. - * - * Object definition is copied from base repo to fully mimick its behavior. - * Image data is added as additional public properties `imageId` and `tileId`. - */ -class ExtendedAttrsImage implements IExtendedAttrsImage { - private _ext: number = 0; - public get ext(): number { - if (this._urlId) { - return ( - (this._ext & ~ExtFlags.UNDERLINE_STYLE) | - (this.underlineStyle << 26) - ); - } - return this._ext; - } - public set ext(value: number) { this._ext = value; } - - public get underlineStyle(): UnderlineStyle { - // Always return the URL style if it has one - if (this._urlId) { - return UnderlineStyle.DASHED; - } - return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26; - } - public set underlineStyle(value: UnderlineStyle) { - this._ext &= ~ExtFlags.UNDERLINE_STYLE; - this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE; - } - - public get underlineColor(): number { - return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); - } - public set underlineColor(value: number) { - this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); - this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); - } - - public get underlineVariantOffset(): number { - const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29; - if (val < 0) { - return val ^ 0xFFFFFFF8; - } - return val; - } - public set underlineVariantOffset(value: number) { - this._ext &= ~ExtFlags.VARIANT_OFFSET; - this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET; - } - - private _urlId: number = 0; - public get urlId(): number { - return this._urlId; - } - public set urlId(value: number) { - this._urlId = value; - } - +class ImageTileInfo { constructor( - ext: number = 0, - urlId: number = 0, public imageId = -1, - public tileId = -1 - ) { - this._ext = ext; - this._urlId = urlId; - } - - public clone(): IExtendedAttrsImage { - /** - * Technically we dont need a clone variant of ExtendedAttrsImage, - * as we never clone a cell holding image data. - * Note: Clone is only meant to be used by the InputHandler for - * sticky attributes, which is never the case for image data. - * We still provide a proper clone method to reflect the full ext attr - * state in case there are future use cases for clone. - */ - return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId); - } - - public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1; + public tileId = -1) { } } -const EMPTY_ATTRS = new ExtendedAttrsImage(); - /** * ImageStorage - extension of CoreTerminal: @@ -122,6 +44,7 @@ export class ImageStorage implements IDisposable { private _needsFullClear = false; // hard limit of stored pixels (fallback limit of 10 MB) private _pixelLimit: number = 2500000; + private _workCell: CellData = new CellData(); private _viewportMetrics: { cols: number, rows: number }; public onImageAdded: (() => void) | undefined; @@ -271,10 +194,10 @@ export class ImageStorage implements IDisposable { this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); for (let row = 0; row < rows; ++row) { - const line = buffer.lines.get(buffer.y + buffer.ybase); + const line = buffer.lines.get(buffer.y + buffer.ybase)!; for (let col = 0; col < cols; ++col) { if (offset + col >= termCols) break; - this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col); + this._writeToCell(line, offset + col, imageId, row * cols + col); tileCount++; } if (scrolling) { @@ -417,22 +340,18 @@ export class ImageStorage implements IDisposable { const placeholderCalls: { col: number, row: number, count: number }[] = []; // walk all cells in viewport and collect tiles found - // Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag) + // Note: We check extended directly (not just HAS_EXTENDED flag) // because text writes clear the BG flag but leave image tile data intact. // This lets top-layer images survive text overwrites (kitty C=1 behavior). for (let row = start; row <= end; ++row) { - const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt; + const line = buffer.lines.get(row + buffer.ydisp); if (!line) return; + const workCell = this._workCell; for (let col = 0; col < cols; ++col) { - let e: IExtendedAttrsImage; - if (line.getBg(col) & BgFlags.HAS_EXTENDED) { - e = line._extendedAttrs[col] ?? EMPTY_ATTRS; - } else { - const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) { - continue; - } - e = maybeImg; + line.loadCell(col, workCell); + let e = workCell.hasExtendedAttrs() && workCell.extended.payload; + if (! (e instanceof ImageTileInfo)) { + continue; } const imageId = e.imageId; if (imageId === undefined || imageId === -1) { @@ -451,8 +370,9 @@ export class ImageStorage implements IDisposable { * Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. */ while (++col < cols) { - const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { + line.loadCell(col, workCell); + const nextE = workCell.hasExtendedAttrs() && workCell.extended.payload; + if (! (nextE instanceof ImageTileInfo) || !nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { break; } e = nextE; @@ -503,10 +423,15 @@ export class ImageStorage implements IDisposable { const buffer = this._terminal._core.buffer; const rows = buffer.lines.length; const oldCol = this._viewportMetrics.cols - 1; + const workCell = this._workCell; for (let row = 0; row < rows; ++row) { - const line = buffer.lines.get(row) as IBufferLineExt; - if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS; + const line = buffer.lines.get(row) !; + line.loadCell(oldCol, workCell); + if (workCell.hasExtendedAttrs()) { + const e = workCell.extended.payload; + if (! (e instanceof ImageTileInfo)) { + continue; + } const imageId = e.imageId; if (imageId === undefined || imageId === -1) { continue; @@ -523,7 +448,7 @@ export class ImageStorage implements IDisposable { // expand only if right side is empty (nothing got wrapped from below) let hasData = false; for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) { - if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) { + if (line.hasContent(rightCol)) { hasData = true; break; } @@ -535,7 +460,7 @@ export class ImageStorage implements IDisposable { const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); let lastTile = e.tileId; for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { - this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile); + this._writeToCell(line, expandCol, imageId, ++lastTile); imgSpec.tileCount++; } } @@ -549,10 +474,10 @@ export class ImageStorage implements IDisposable { */ public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; - const line = buffer.lines.get(y) as IBufferLineExt; - if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS; - if (e.imageId && e.imageId !== -1) { + const line = buffer.lines.get(y); + if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { + const e = this._workCell.extended.payload; + if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) { const orig = this._images.get(e.imageId)?.orig; if (window.ImageBitmap && orig instanceof ImageBitmap) { const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height); @@ -569,10 +494,10 @@ export class ImageStorage implements IDisposable { */ public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; - const line = buffer.lines.get(y) as IBufferLineExt; - if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) { - const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS; - if (e.imageId && e.imageId !== -1 && e.tileId !== -1) { + const line = buffer.lines.get(y); + if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { + const e = this._workCell.extended.payload; + if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) { const spec = this._images.get(e.imageId); if (spec) { return this._renderer.extractTile(spec, e.tileId); @@ -600,31 +525,34 @@ export class ImageStorage implements IDisposable { return used - current; } - private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void { - if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - const old = line._extendedAttrs[x]; - if (old) { - if (old.imageId !== undefined) { - // found an old ExtendedAttrsImage, since we know that - // they are always isolated instances (single cell usage), - // we can re-use it and just update their id entries - const oldSpec = this._images.get(old.imageId); - if (oldSpec) { - // early eviction for in-viewport overwrites - oldSpec.tileCount--; - } - old.imageId = imageId; - old.tileId = tileId; - return; + private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void { + const workCell = this._workCell; + line.loadCell(x, workCell); + if (this._workCell.hasExtendedAttrs()) { + const old = workCell.extended.payload; + if (old instanceof ImageTileInfo) { + // found an old ExtendedAttrsImage, since we know that + // they are always isolated instances (single cell usage), + // we can re-use it and just update their id entries + const oldSpec = this._images.get(old.imageId); + if (oldSpec) { + // early eviction for in-viewport overwrites + oldSpec.tileCount--; } - // found a plain ExtendedAttrs instance, clone it to new entry - line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId); + old.imageId = imageId; + old.tileId = tileId; return; } + // found a plain ExtendedAttrs instance + workCell.extended.payload = new ImageTileInfo(imageId, tileId); + return; } // fall-through: always create new ExtendedAttrsImage entry - line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED; - line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId); + const extattr = workCell.extended.clone(); + extattr.payload = new ImageTileInfo(imageId, tileId); + workCell.extended = extattr; + workCell.updateExtended(); + line.setCell(x, workCell); } private _evictOnAlternate(): void { @@ -637,15 +565,17 @@ export class ImageStorage implements IDisposable { // re-count tiles on whole buffer const buffer = this._terminal._core.buffer; for (let y = 0; y < this._terminal.rows; ++y) { - const line = buffer.lines.get(y) as IBufferLineExt; + const line = buffer.lines.get(y); if (!line) { continue; } + const workCell = this._workCell; for (let x = 0; x < this._terminal.cols; ++x) { - if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { - const imgId = line._extendedAttrs[x]?.imageId; - if (imgId) { - const spec = this._images.get(imgId); + line.loadCell(x, workCell); + if (workCell.hasExtendedAttrs()) { + const payload = workCell.extended.payload; + if (payload instanceof ImageTileInfo) { + const spec = this._images.get(payload.imageId); if (spec) { spec.tileCount++; } diff --git a/addons/addon-image/src/Types.ts b/addons/addon-image/src/Types.ts index 80cec9e243..675c9fdd61 100644 --- a/addons/addon-image/src/Types.ts +++ b/addons/addon-image/src/Types.ts @@ -9,7 +9,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm'; import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants'; import type { AttributeData } from 'common/buffer/AttributeData'; import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types'; -import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types'; +import type { IInputHandler } from 'common/Types'; import type { ITerminal, ReadonlyColorSet } from 'browser/Types'; import type { IRenderDimensions } from 'browser/renderer/shared/Types'; import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; @@ -47,6 +47,7 @@ export interface IResetHandler { reset(): void; } +/* eslint-disable */ /** * Stub into private interfaces. * This should be kept in line with common libs. @@ -54,19 +55,6 @@ export interface IResetHandler { * have a somewhat reliable testing against code changes in the core repo. */ -// overloaded IExtendedAttrs to hold image refs -export interface IExtendedAttrsImage extends IExtendedAttrs { - imageId: number; - tileId: number; - clone(): IExtendedAttrsImage; -} - -/* eslint-disable */ -export interface IBufferLineExt extends IBufferLine { - _extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined}; - _data: Uint32Array; -} - interface IInputHandlerExt extends IInputHandler { _parser: IEscapeSequenceParser; _curAttrData: AttributeData; @@ -77,6 +65,7 @@ interface IInputHandlerExt extends IInputHandler { }; onRequestReset(handler: () => void): IDisposable; } +/* eslint-enable */ export interface ICoreTerminalExt extends ITerminal { _themeService: IThemeService | undefined; @@ -88,8 +77,6 @@ export interface ICoreTerminalExt extends ITerminal { export interface ITerminalExt extends Terminal { _core: ICoreTerminalExt; } -/* eslint-enable */ - /** * Some storage definitions. diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index dc729ec63f..f996b8d769 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -98,32 +98,6 @@ test.describe('ImageAddon', () => { `); }); - test('test for private accessors', async () => { - // terminal privates - const accessors = [ - '_core', - '_core._renderService', - '_core._inputHandler', - '_core._inputHandler._parser', - '_core._inputHandler._curAttrData', - '_core._inputHandler._dirtyRowTracker', - '_core._themeService.colors', - '_core._coreBrowserService' - ]; - for (const prop of accessors) { - strictEqual( - await ctx.page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'), - true, `problem at ${prop}` - ); - } - // bufferline privates - strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true); - strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true); - // inputhandler privates - strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), '_AttributeData'); - strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser'); - }); - test.describe('ctor options', () => { test('empty settings should load defaults', async () => { const DEFAULT_OPTIONS: IImageAddonOptions = { diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index fb42d807dc..956babcad0 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, diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index 3a4661cc31..9d8ed47c8b 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -3,7 +3,7 @@ import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { ReadonlyColorSet } from 'browser/Types'; import { Attributes, BgFlags, ExtFlags, FgFlags, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants'; import { IDecorationService, IOptionsService } from 'common/services/Services'; -import { ICellData } from 'common/Types'; +import { ICellData, IBufferLine } from 'common/Types'; import { Terminal } from '@xterm/xterm'; import { rgba } from 'common/Color'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; @@ -43,7 +43,7 @@ export class CellColorResolver { * Resolves colors for the cell, putting the result into the shared {@link result}. This resolves * overrides, inverse and selection for the cell which can then be used to feed into the renderer. */ - public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { + public resolve(line: IBufferLine, cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -68,7 +68,7 @@ export class CellColorResolver { $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); } // Apply decorations on the bottom layer - this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { + this._decorationService.forEachDecorationAtCellLine(x, y, 'bottom', d => { if (d.backgroundColorRGB) { $bg = d.backgroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasBg = true; @@ -77,7 +77,7 @@ export class CellColorResolver { $fg = d.foregroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasFg = true; } - }); + }, line); // Apply the selection color if needed $isSelected = this._selectionRenderModel.isCellSelected(this._terminal, x, y); @@ -175,7 +175,7 @@ export class CellColorResolver { } // Apply decorations on the top layer - this._decorationService.forEachDecorationAtCell(x, y, 'top', d => { + this._decorationService.forEachDecorationAtCellLine(x, y, 'top', d => { if (d.backgroundColorRGB) { $bg = d.backgroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasBg = true; @@ -184,7 +184,7 @@ export class CellColorResolver { $fg = d.foregroundColorRGB.rgba >> 8 & Attributes.RGB_MASK; $hasFg = true; } - }); + }, line); // Convert any overrides from rgba to the fg/bg packed format. This resolves the inverse flag // ahead of time in order to use the correct cache key diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 500308bd1d..128ec5838a 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -517,7 +517,7 @@ export class WebglRenderer extends Disposable implements IRenderer { } // Load colors/resolve overrides into work colors - this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); + this._cellColorResolver.resolve(line, cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 52eb000256..0dbc1b0db5 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -763,6 +763,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } } + /** + * This is somewhat expensive operation. + * @deprecated + */ public get markers(): IMarker[] { return this.buffer.markers; } diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 65a18da721..d335f43c22 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -8,7 +8,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseS import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; import { IColorSet, ITerminal, ILinkifier2, IBrowser, IViewport, ICompositionHelper, CharacterJoinerHandler, IBufferRange, ReadonlyColorSet, IBufferElementProvider } from 'browser/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset, ITerminalOptions, ColorIndex } from 'common/Types'; +import { IBufferLine, ICellData, IAttributeData, ICircularList, ILogicalLine, XtermListener, ICharset, ITerminalOptions, ColorIndex } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import * as Browser from 'common/Platform'; import { CoreBrowserTerminal } from 'browser/CoreBrowserTerminal'; @@ -255,7 +255,7 @@ export class MockBuffer implements IBuffer { public setLines(lines: ICircularList): void { this.lines = lines; } - public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { + public getBlankLine(attr: IAttributeData, logicalLine?: ILogicalLine): IBufferLine { return Buffer.prototype.getBlankLine.apply(this, arguments as any); } public getNullCell(attr?: IAttributeData): ICellData { @@ -270,6 +270,9 @@ export class MockBuffer implements IBuffer { public clearAllMarkers(): void { throw new Error('Method not implemented.'); } + public setWrapped(row: number, value: boolean): void { + throw new Error('Method not implemented.'); + } } export class MockRenderer implements IRenderer { diff --git a/src/browser/decorations/BufferDecorationRenderer.ts b/src/browser/decorations/BufferDecorationRenderer.ts index e374af2609..94091c5dce 100644 --- a/src/browser/decorations/BufferDecorationRenderer.ts +++ b/src/browser/decorations/BufferDecorationRenderer.ts @@ -69,13 +69,13 @@ export class BufferDecorationRenderer extends Disposable { } } - private _createElement(decoration: IInternalDecoration): HTMLElement { + private _createElement(decoration: IInternalDecoration, line: number): HTMLElement { const element = this._coreBrowserService.mainDocument.createElement('div'); element.classList.add('xterm-decoration'); element.classList.toggle('xterm-decoration-top-layer', decoration?.options?.layer === 'top'); element.style.width = `${Math.round((decoration.options.width || 1) * this._renderService.dimensions.css.cell.width)}px`; element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.css.cell.height}px`; - element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`; + element.style.top = `${(line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`; element.style.lineHeight = `${this._renderService.dimensions.css.cell.height}px`; const x = decoration.options.x ?? 0; @@ -99,7 +99,7 @@ export class BufferDecorationRenderer extends Disposable { } else { let element = this._decorationElements.get(decoration); if (!element) { - element = this._createElement(decoration); + element = this._createElement(decoration, line); decoration.element = element; this._decorationElements.set(decoration, element); this._container.appendChild(element); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 0cff9c25fd..49204a3c1f 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -168,9 +168,9 @@ export class DomRendererRowFactory { } let isDecorated = false; - this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { + this._decorationService.forEachDecorationAtCellLine(x, row, undefined, d => { isDecorated = true; - }); + }, lineData); // get chars to render for this cell let chars = cell.getChars() || WHITESPACE_CELL_CHAR; @@ -358,7 +358,7 @@ export class DomRendererRowFactory { let bgOverride: IColor | undefined; let fgOverride: IColor | undefined; let isTop = false; - this._decorationService.forEachDecorationAtCell(x, row, undefined, d => { + this._decorationService.forEachDecorationAtCellLine(x, row, undefined, d => { if (d.options.layer !== 'top' && isTop) { return; } @@ -373,7 +373,7 @@ export class DomRendererRowFactory { fgOverride = d.foregroundColorRGB; } isTop = d.options.layer === 'top'; - }); + }, lineData); // Apply selection if (!isTop && isInSelection) { diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index be62ccc6b7..56d4b45711 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -196,7 +196,7 @@ describe('SelectionService', () => { it('should expand upwards or downards for wrapped lines', () => { buffer.lines.set(0, stringToRow(' foo')); buffer.lines.set(1, stringToRow('bar ')); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); selectionService.selectWordAt([1, 1]); assert.equal(selectionService.selectionText, 'foobar'); selectionService.model.clearSelection(); @@ -210,10 +210,10 @@ describe('SelectionService', () => { buffer.lines.set(2, stringToRow('bbbbbbbbbbbbbbbbbbbb')); buffer.lines.set(3, stringToRow('cccccccccccccccccccc')); buffer.lines.set(4, stringToRow('bar ')); - buffer.lines.get(1)!.isWrapped = true; - buffer.lines.get(2)!.isWrapped = true; - buffer.lines.get(3)!.isWrapped = true; - buffer.lines.get(4)!.isWrapped = true; + buffer.setWrapped(1, true); + buffer.setWrapped(2, true); + buffer.setWrapped(3, true); + buffer.setWrapped(4, true); selectionService.selectWordAt([18, 0]); assert.equal(selectionService.selectionText, expectedText); selectionService.model.clearSelection(); @@ -352,8 +352,8 @@ describe('SelectionService', () => { it('should select the entire wrapped line', () => { buffer.lines.set(0, stringToRow('foo')); const line2 = stringToRow('bar'); - line2.isWrapped = true; buffer.lines.set(1, line2); + buffer.setWrapped(1, true); selectionService.selectLineAt(0); assert.equal(selectionService.selectionText, 'foobar', 'The selected text is correct'); assert.deepEqual(selectionService.model.selectionStart, [0, 0]); diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 3663bfc178..bdefe5d723 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -106,29 +106,18 @@ export class CircularList extends Disposable implements ICircularList { * @param value The value to push onto the list. */ public push(value: T): void { + const trimNeeded = this._length === this._maxLength; + if (trimNeeded) { + this.onTrimEmitter.fire(1); + } this._array[this._getCyclicIndex(this._length)] = value; - if (this._length === this._maxLength) { + if (trimNeeded) { this._startIndex = ++this._startIndex % this._maxLength; - this.onTrimEmitter.fire(1); } else { this._length++; } } - /** - * 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. */ @@ -154,34 +143,61 @@ export class CircularList extends Disposable implements ICircularList { * @param items The items to insert. */ public splice(start: number, deleteCount: number, ...items: T[]): void { + this.spliceItems(start, deleteCount, items, false); + } + + public spliceItems(start: number, deleteCount: number, items: T[], onlyTrimEvents: boolean): void { + let trimTodo = Math.max(0, (this._length + items.length - deleteCount) - this._maxLength); + const preTrim = Math.min(start, trimTodo); + if (preTrim > 0) { + this.trimStart(preTrim); + trimTodo -= preTrim; + start -= preTrim; + } // Delete items if (deleteCount) { + if (! onlyTrimEvents) { + this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); + } for (let i = start; i < this._length - deleteCount; i++) { this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; } this._length -= deleteCount; - this.onDeleteEmitter.fire({ index: start, amount: deleteCount }); } - - // Add items - for (let i = this._length - 1; i >= start; i--) { - this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)]; + const postTrim = trimTodo - items.length; + if (postTrim > 0) { + this.trimStart(postTrim); + trimTodo -= postTrim; } - for (let i = 0; i < items.length; i++) { - this._array[this._getCyclicIndex(start + i)] = items[i]; - } - if (items.length) { - this.onInsertEmitter.fire({ index: start, amount: items.length }); - } - - // Adjust length as needed - if (this._length + items.length > this._maxLength) { - const countToTrim = (this._length + items.length) - this._maxLength; - this._startIndex += countToTrim; - this._length = this._maxLength; - this.onTrimEmitter.fire(countToTrim); - } else { - this._length += items.length; + // If trimTodo > 0 we will have to trim some of the inserted items. + // Copy in chunks (as many items as will not exceed _maxLength) + // in order to fire trim and insert events properly. + let firstItem = 0; // next index in items array + let itemsTodo = items.length; // number of items yet to insert + while (itemsTodo > 0) { + const availSpace = this._maxLength - this.length; + // Number of items we can insert this iteration. + const itemsAvail = Math.min(availSpace, itemsTodo); + // Add items + for (let i = this._length - 1; i >= start; i--) { + this._array[this._getCyclicIndex(i + itemsAvail)] = this._array[this._getCyclicIndex(i)]; + } + for (let i = 0; i < itemsAvail; i++) { + this._array[this._getCyclicIndex(start + i)] = items[firstItem + i]; + } + this._length += itemsAvail; + if (items.length && ! onlyTrimEvents) { + this.onInsertEmitter.fire({ index: start, amount: itemsAvail }); + } + if (trimTodo > 0) { + const trimAvail = Math.min(trimTodo, itemsAvail); + this.trimStart(trimAvail); + trimTodo -= trimAvail; + start -= trimAvail; + } + itemsTodo -= itemsAvail; + firstItem += itemsAvail; + start += itemsAvail; } } @@ -193,9 +209,9 @@ export class CircularList extends Disposable implements ICircularList { if (count > this._length) { count = this._length; } + this.onTrimEmitter.fire(count); this._startIndex += count; this._length -= count; - this.onTrimEmitter.fire(count); } public shiftElements(start: number, count: number, offset: number): void { @@ -217,9 +233,9 @@ export class CircularList extends Disposable implements ICircularList { if (expandListBy > 0) { this._length += expandListBy; while (this._length > this._maxLength) { + this.onTrimEmitter.fire(1); this._length--; this._startIndex++; - this.onTrimEmitter.fire(1); } } } else { diff --git a/src/common/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 2b7142dd02..02e9852135 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -578,8 +578,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) { @@ -591,7 +591,7 @@ export class InputHandler extends Disposable implements IInputHandler { } // The line already exists (eg. the initial viewport), mark it as a // wrapped line - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, true); } // row changed, get it again bufferRow = this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y); @@ -605,9 +605,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) { @@ -755,7 +753,7 @@ export class InputHandler extends Disposable implements IInputHandler { // reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics // can mess with this so windowsMode should be disabled, which is recommended on Windows build // 21376 and above. - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, false); } // If the end of the line is hit, prevent this action from wrapping around to the next line. if (this._activeBuffer.x >= this._bufferService.cols) { @@ -819,7 +817,7 @@ export class InputHandler extends Disposable implements IInputHandler { && this._activeBuffer.y > this._activeBuffer.scrollTop && this._activeBuffer.y <= this._activeBuffer.scrollBottom && this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) { - this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false; + this._activeBuffer.setWrapped(this._activeBuffer.ybase + this._activeBuffer.y, false); this._activeBuffer.y--; this._activeBuffer.x = this._bufferService.cols - 1; // find last taken cell - last cell can have 3 different states: @@ -1165,25 +1163,34 @@ 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) { - line.isWrapped = false; + if (! respectProtect && end >= this._bufferService.cols) { + const next = line.nextBufferLine; + if (next) next.asUnwrapped(line); + line.eraseRight(start); + line.logical().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); } } @@ -1193,12 +1200,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); - line.isWrapped = false; - } + this._eraseInBufferLine(y, 0, this._bufferService.cols, respectProtect); + this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); } /** @@ -1232,7 +1235,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); } @@ -1242,14 +1245,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. - const nextLine = this._activeBuffer.lines.get(j + 1); - if (nextLine) { - nextLine.isWrapped = false; - } - } + this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, respectProtect); while (j--) { this._resetBufferLine(j, respectProtect); } @@ -1319,13 +1315,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); @@ -1510,9 +1506,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; + this._activeBuffer.setWrapped(row, false); } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1543,9 +1540,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; + this._activeBuffer.setWrapped(row, false); line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1566,9 +1564,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + this._activeBuffer.setWrapped(row, false); + const line = this._activeBuffer.lines.get(row)!; line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -1589,9 +1588,10 @@ export class InputHandler extends Disposable implements IInputHandler { } const param = params.params[0] || 1; for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const row = this._activeBuffer.ybase + y; + const line = this._activeBuffer.lines.get(row)!; + this._activeBuffer.setWrapped(row, false); line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData())); - line.isWrapped = false; } this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom); return true; @@ -3476,11 +3476,8 @@ export class InputHandler extends Disposable implements IInputHandler { this._setCursor(0, 0); for (let yOffset = 0; yOffset < this._bufferService.rows; ++yOffset) { const row = this._activeBuffer.ybase + this._activeBuffer.y + yOffset; - const line = this._activeBuffer.lines.get(row); - if (line) { - line.fill(cell); - line.isWrapped = false; - } + this._activeBuffer.setWrapped(row, false); + this._activeBuffer.lines.get(row)?.fill(cell); } this._dirtyRowTracker.markAllDirty(); this._setCursor(0, 0); diff --git a/src/common/SortedList.test.ts b/src/common/SortedList.test.ts deleted file mode 100644 index 4718cf971b..0000000000 --- a/src/common/SortedList.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2018 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { SortedList } from 'common/SortedList'; -import { MockLogService } from 'common/TestUtils.test'; - -const deepStrictEqual = assert.deepStrictEqual; - -describe('SortedList', () => { - let list: SortedList; - function assertList(expected: number[]): void { - deepStrictEqual(Array.from(list.values()), expected); - } - - beforeEach(() => { - list = new SortedList(e => e, new MockLogService()); - }); - - describe('insert', () => { - it('should maintain sorted values', () => { - list.insert(10); - assertList([10]); - list.insert(8); - assertList([8, 10]); - list.insert(15); - assertList([8, 10, 15]); - list.insert(2); - assertList([2, 8, 10, 15]); - list.insert(1); - assertList([1, 2, 8, 10, 15]); - list.insert(6); - assertList([1, 2, 6, 8, 10, 15]); - }); - it('should allow duplicates of the same key', () => { - list.insert(5); - assertList([5]); - list.insert(5); - assertList([5, 5]); - list.insert(8); - assertList([5, 5, 8]); - list.insert(5); - assertList([5, 5, 5, 8]); - list.insert(8); - assertList([5, 5, 5, 8, 8]); - list.insert(6); - assertList([5, 5, 5, 6, 8, 8]); - }); - }); - it('delete', () => { - list.insert(1); - list.insert(2); - list.insert(4); - list.insert(3); - list.insert(5); - assertList([1, 2, 3, 4, 5]); - list.delete(1); - assertList([2, 3, 4, 5]); - list.delete(3); - assertList([2, 4, 5]); - list.delete(4); - assertList([2, 5]); - list.delete(5); - assertList([2]); - list.delete(2); - assertList([]); - }); - it('getKeyIterator', () => { - list.insert(5); - list.insert(5); - list.insert(8); - list.insert(5); - list.insert(8); - list.insert(6); - assertList([5, 5, 5, 6, 8, 8]); - deepStrictEqual(Array.from(list.getKeyIterator(1)), []); - deepStrictEqual(Array.from(list.getKeyIterator(5)), [5, 5, 5]); - deepStrictEqual(Array.from(list.getKeyIterator(6)), [6]); - deepStrictEqual(Array.from(list.getKeyIterator(8)), [8, 8]); - deepStrictEqual(Array.from(list.getKeyIterator(9)), []); - }); - it('clear', () => { - list.insert(1); - list.insert(2); - list.insert(4); - list.insert(3); - list.insert(5); - list.clear(); - assertList([]); - }); - it('custom key', () => { - const customList = new SortedList<{ key: number }>(e => e.key, new MockLogService()); - customList.insert({ key: 5 }); - customList.insert({ key: 2 }); - customList.insert({ key: 10 }); - customList.insert({ key: 5 }); - customList.insert({ key: 6 }); - deepStrictEqual(Array.from(customList.values()), [ - { key: 2 }, - { key: 5 }, - { key: 5 }, - { key: 6 }, - { key: 10 } - ]); - }); - describe('values', () => { - it('should iterate correctly when list items change during iteration', () => { - list.insert(1); - list.insert(2); - list.insert(3); - list.insert(4); - const visited: number[] = []; - for (const item of list.values()) { - visited.push(item); - list.delete(item); - } - deepStrictEqual(visited, [1, 2, 3, 4]); - }); - }); -}); diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts deleted file mode 100644 index c6dc62085c..0000000000 --- a/src/common/SortedList.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Copyright (c) 2022 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IdleTaskQueue } from 'common/TaskQueue'; -import type { ILogService } from 'common/services/Services'; - -// Work variables to avoid garbage collection. -let i = 0; - -/** - * A generic list that is maintained in sorted order and allows values with duplicate keys. Deferred - * batch insertion and deletion is used to significantly reduce the time it takes to insert and - * delete a large amount of items in succession. This list is based on binary search and as such - * locating a key will take O(log n) amortized, this includes the by key iterator. - */ -export class SortedList { - private _array: T[] = []; - - private readonly _insertedValues: T[] = []; - private readonly _flushInsertedTask: InstanceType; - private _isFlushingInserted = false; - - private readonly _deletedIndices: number[] = []; - private readonly _flushDeletedTask: InstanceType; - private _isFlushingDeleted = false; - - constructor( - private readonly _getKey: (value: T) => number, - logService: ILogService - ) { - this._flushInsertedTask = new IdleTaskQueue(logService); - this._flushDeletedTask = new IdleTaskQueue(logService); - } - - public clear(): void { - this._array.length = 0; - this._insertedValues.length = 0; - this._flushInsertedTask.clear(); - this._isFlushingInserted = false; - this._deletedIndices.length = 0; - this._flushDeletedTask.clear(); - this._isFlushingDeleted = false; - } - - public insert(value: T): void { - this._flushCleanupDeleted(); - if (this._insertedValues.length === 0) { - this._flushInsertedTask.enqueue(() => this._flushInserted()); - } - this._insertedValues.push(value); - } - - private _flushInserted(): void { - const sortedAddedValues = this._insertedValues.sort((a, b) => this._getKey(a) - this._getKey(b)); - let sortedAddedValuesIndex = 0; - let arrayIndex = 0; - - const newArray = new Array(this._array.length + this._insertedValues.length); - - for (let newArrayIndex = 0; newArrayIndex < newArray.length; newArrayIndex++) { - if (arrayIndex >= this._array.length || this._getKey(sortedAddedValues[sortedAddedValuesIndex]) <= this._getKey(this._array[arrayIndex])) { - newArray[newArrayIndex] = sortedAddedValues[sortedAddedValuesIndex]; - sortedAddedValuesIndex++; - } else { - newArray[newArrayIndex] = this._array[arrayIndex++]; - } - } - - this._array = newArray; - this._insertedValues.length = 0; - } - - private _flushCleanupInserted(): void { - if (!this._isFlushingInserted && this._insertedValues.length > 0) { - this._flushInsertedTask.flush(); - } - } - - public delete(value: T): boolean { - this._flushCleanupInserted(); - if (this._array.length === 0) { - return false; - } - const key = this._getKey(value); - if (key === undefined) { - return false; - } - i = this._search(key); - if (i === -1) { - return false; - } - if (this._getKey(this._array[i]) !== key) { - return false; - } - do { - if (this._array[i] === value) { - if (this._deletedIndices.length === 0) { - this._flushDeletedTask.enqueue(() => this._flushDeleted()); - } - this._deletedIndices.push(i); - return true; - } - } while (++i < this._array.length && this._getKey(this._array[i]) === key); - return false; - } - - private _flushDeleted(): void { - this._isFlushingDeleted = true; - const sortedDeletedIndices = this._deletedIndices.sort((a, b) => a - b); - let sortedDeletedIndicesIndex = 0; - const newArray = new Array(this._array.length - sortedDeletedIndices.length); - let newArrayIndex = 0; - for (let i = 0; i < this._array.length; i++) { - if (sortedDeletedIndices[sortedDeletedIndicesIndex] === i) { - sortedDeletedIndicesIndex++; - } else { - newArray[newArrayIndex++] = this._array[i]; - } - } - this._array = newArray; - this._deletedIndices.length = 0; - this._isFlushingDeleted = false; - } - - private _flushCleanupDeleted(): void { - if (!this._isFlushingDeleted && this._deletedIndices.length > 0) { - this._flushDeletedTask.flush(); - } - } - - public *getKeyIterator(key: number): IterableIterator { - this._flushCleanupInserted(); - this._flushCleanupDeleted(); - if (this._array.length === 0) { - return; - } - i = this._search(key); - if (i < 0 || i >= this._array.length) { - return; - } - if (this._getKey(this._array[i]) !== key) { - return; - } - do { - yield this._array[i]; - } while (++i < this._array.length && this._getKey(this._array[i]) === key); - } - - public forEachByKey(key: number, callback: (value: T) => void): void { - this._flushCleanupInserted(); - this._flushCleanupDeleted(); - if (this._array.length === 0) { - return; - } - i = this._search(key); - if (i < 0 || i >= this._array.length) { - return; - } - if (this._getKey(this._array[i]) !== key) { - return; - } - do { - callback(this._array[i]); - } while (++i < this._array.length && this._getKey(this._array[i]) === key); - } - - public values(): IterableIterator { - this._flushCleanupInserted(); - this._flushCleanupDeleted(); - // Duplicate the array to avoid issues when _array changes while iterating - return [...this._array].values(); - } - - private _search(key: number): number { - let min = 0; - let max = this._array.length - 1; - while (max >= min) { - let mid = (min + max) >> 1; - const midKey = this._getKey(this._array[mid]); - if (midKey > key) { - max = mid - 1; - } else if (midKey < key) { - min = mid + 1; - } else { - // key in list, walk to lowest duplicate - while (mid > 0 && this._getKey(this._array[mid - 1]) === key) { - mid--; - } - return mid; - } - } - // key not in list - // still return closest min (also used as insert position) - return min; - } -} diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 5940fa6596..63cce550d0 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -239,5 +239,6 @@ export class MockDecorationService implements IDecorationService { public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { return undefined; } public reset(): void { } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { } + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: IBufferLine): void { } public dispose(): void { } } diff --git a/src/common/Types.ts b/src/common/Types.ts index 5edf63637b..846bb79c24 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; @@ -115,6 +114,7 @@ export interface IExtendedAttrs { underlineColor: number; underlineVariantOffset: number; urlId: number; + payload: Object | undefined; clone(): IExtendedAttrs; isEmpty(): boolean; } @@ -219,12 +219,20 @@ export interface ICellData extends IAttributeData { getAsCharData(): CharData; } +export interface ILogicalLine { + forEachMarker(callback: (marker: IMarker) => void): void; + reflowNeeded: boolean; + isEmpty(): boolean; +} + /** * Interface for a line in the terminal buffer. */ export interface IBufferLine { + logical(): ILogicalLine; + startColumn: number; length: number; - isWrapped: boolean; + get isWrapped(): boolean; get(index: number): CharData; set(index: number, value: CharData): void; loadCell(index: number, cell: ICellData): ICellData; @@ -238,7 +246,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; @@ -255,10 +262,17 @@ export interface IBufferLine { } export interface IMarker extends IDisposable { + /** + * @deprecated + */ readonly id: number; readonly isDisposed: boolean; + /** + * @deprecated + */ readonly line: number; onDispose: IEvent; + payload?: IDisposable; } export interface IModes { insertMode: boolean; diff --git a/src/common/WindowsMode.ts b/src/common/WindowsMode.ts index 7cff094b2c..22ba1e2a92 100644 --- a/src/common/WindowsMode.ts +++ b/src/common/WindowsMode.ts @@ -19,9 +19,7 @@ export function updateWindowsModeWrappedState(bufferService: IBufferService): vo // wrapped. const line = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y - 1); const lastChar = line?.get(bufferService.cols - 1); - - const nextLine = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y); - if (nextLine && lastChar) { - nextLine.isWrapped = (lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE); + if (lastChar) { + bufferService.buffer.setWrapped(bufferService.buffer.ybase + bufferService.buffer.y, lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE); } } diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 6221fb81d2..d81922105f 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -138,6 +138,7 @@ export class AttributeData implements IAttributeData { */ export class ExtendedAttrs implements IExtendedAttrs { private _ext: number = 0; + public payload: Object | undefined; public get ext(): number { if (this._urlId) { return ( @@ -206,6 +207,6 @@ export class ExtendedAttrs implements IExtendedAttrs { * that needs to be persistant in the buffer. */ public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0; + return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined; } } diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index abfea7b061..3b40f50ffb 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -80,40 +80,40 @@ describe('Buffer', () => { describe('wrapped', () => { it('should return a range for the first row', () => { buffer.fillViewportRows(); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 1 }); }); it('should return a range for a middle row wrapping upwards', () => { buffer.fillViewportRows(); - buffer.lines.get(12)!.isWrapped = true; + buffer.setWrapped(12, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 11, last: 12 }); }); it('should return a range for a middle row wrapping downwards', () => { buffer.fillViewportRows(); - buffer.lines.get(13)!.isWrapped = true; + buffer.setWrapped(13, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 13 }); }); it('should return a range for a middle row wrapping both ways', () => { buffer.fillViewportRows(); - buffer.lines.get(11)!.isWrapped = true; - buffer.lines.get(12)!.isWrapped = true; - buffer.lines.get(13)!.isWrapped = true; - buffer.lines.get(14)!.isWrapped = true; + buffer.setWrapped(11, true); + buffer.setWrapped(12, true); + buffer.setWrapped(13, true); + buffer.setWrapped(14, true); assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 10, last: 14 }); }); it('should return a range for the last row', () => { buffer.fillViewportRows(); - buffer.lines.get(23)!.isWrapped = true; + buffer.setWrapped(23, true); assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 22, last: 23 }); }); it('should return a range for a row that wraps upward to first row', () => { buffer.fillViewportRows(); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); assert.deepEqual(buffer.getWrappedRangeForLine(1), { first: 0, last: 1 }); }); it('should return a range for a row that wraps downward to last row', () => { buffer.fillViewportRows(); - buffer.lines.get(buffer.lines.length - 1)!.isWrapped = true; + buffer.setWrapped(buffer.lines.length - 1, true); assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 2), { first: 22, last: 23 }); }); }); @@ -466,8 +466,8 @@ describe('Buffer', () => { assert.equal(buffer.lines.get(1)!.translateToString(), '0123456789'); assert.equal(buffer.lines.get(2)!.translateToString(), 'klmnopqrst'); assert.equal(firstMarker.line, 0, 'first marker should remain unchanged'); - assert.equal(secondMarker.line, 1, 'second marker should be restored to it\'s original line'); - assert.equal(thirdMarker.line, 2, 'third marker should be restored to it\'s original line'); + assert.equal(secondMarker.line, 1, 'second marker should be restored to its original line'); + assert.equal(thirdMarker.line, 2, 'third marker should be restored to its original line'); assert.equal(firstMarker.isDisposed, false); assert.equal(secondMarker.isDisposed, false); assert.equal(thirdMarker.isDisposed, false); @@ -538,7 +538,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // "ab " (wrapped) // "cd" @@ -569,7 +569,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(i, [0, '', 0, 0]); buffer.lines.get(1)!.set(i, [0, '', 0, 0]); } - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // 汉语汉语汉语 (wrapped) // 汉语汉语汉语 @@ -596,7 +596,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // "ab " (wrapped) // "cd" @@ -630,7 +630,7 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(i, [0, '', 0, 0]); buffer.lines.get(1)!.set(i, [0, '', 0, 0]); } - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); // Buffer: // 汉语汉语汉语 (wrapped) // 汉语汉语汉语 @@ -685,17 +685,17 @@ describe('Buffer', () => { buffer.lines.get(0)!.set(1, [0, 'b', 1, 'b'.charCodeAt(0)]); buffer.lines.get(1)!.set(0, [0, 'c', 1, 'c'.charCodeAt(0)]); buffer.lines.get(1)!.set(1, [0, 'd', 1, 'd'.charCodeAt(0)]); - buffer.lines.get(1)!.isWrapped = true; + buffer.setWrapped(1, true); buffer.lines.get(2)!.set(0, [0, 'e', 1, 'e'.charCodeAt(0)]); buffer.lines.get(2)!.set(1, [0, 'f', 1, 'f'.charCodeAt(0)]); buffer.lines.get(3)!.set(0, [0, 'g', 1, 'g'.charCodeAt(0)]); buffer.lines.get(3)!.set(1, [0, 'h', 1, 'h'.charCodeAt(0)]); - buffer.lines.get(3)!.isWrapped = true; + buffer.setWrapped(3, true); buffer.lines.get(4)!.set(0, [0, 'i', 1, 'i'.charCodeAt(0)]); buffer.lines.get(4)!.set(1, [0, 'j', 1, 'j'.charCodeAt(0)]); buffer.lines.get(5)!.set(0, [0, 'k', 1, 'k'.charCodeAt(0)]); buffer.lines.get(5)!.set(1, [0, 'l', 1, 'l'.charCodeAt(0)]); - buffer.lines.get(5)!.isWrapped = true; + buffer.setWrapped(5, true); }); describe('viewport not yet filled', () => { it('should move the cursor up and add empty lines', () => { @@ -1003,6 +1003,7 @@ describe('Buffer', () => { for (let i = 0; i < 10; i++) { buffer.lines.splice(0, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); } + buffer.y = 9; buffer.ybase = 10; }); describe('&& ydisp === ybase', () => { @@ -1083,9 +1084,9 @@ describe('Buffer', () => { buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); - assert.equal(marker.line, buffer.lines.length - 1); - buffer.lines.onTrimEmitter.fire(1); - assert.equal(marker.line, buffer.lines.length - 2); + assert.equal(marker.line, 23); + buffer.lines.trimStart(1); + assert.equal(marker.line, 22); }); it('should dispose of a marker if it is trimmed off the buffer', () => { buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); @@ -1190,7 +1191,6 @@ describe('Buffer', () => { assert.equal(str3, '😁a'); }); }); - describe('line string cache cleanup', () => { it('should clear shared cache entries with a single timer', () => { const originalSetTimeout = globalThis.setTimeout; @@ -1284,32 +1284,4 @@ describe('Buffer', () => { }); }); - 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 34ef02f6a7..9822be10bc 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -3,14 +3,12 @@ * @license MIT */ -import { CircularList, IInsertEvent } from 'common/CircularList'; -import { Disposable, toDisposable } from 'common/Lifecycle'; -import { IdleTaskQueue } from 'common/TaskQueue'; +import { CircularList } from 'common/CircularList'; +import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; -import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { BufferLine, LogicalLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; -import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } 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'; @@ -31,6 +29,7 @@ export class Buffer extends Disposable 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; @@ -44,14 +43,34 @@ export class Buffer extends Disposable implements IBuffer { public savedGlevel: number = 0; public savedOriginMode: boolean = false; public savedWraparoundMode: boolean = true; - public markers: Marker[] = []; + /** Reflow may be needed for line indexes less than lastReflowNeeded. + * I.e. if i >= lastReflowNeeded then lines.get(i).reflowNeeded is false. + * Lines later in the buffer are more likly to be visible and hence + * have been updated. */ + public lastReflowNeeded: number = 0; + + /** + * This is an expensive operation. + * @deprecated + */ + public get markers(): Marker[] { + const mm: Marker[] = []; + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + const bline = this.lines.get(i) as BufferLine; + const lline = bline.logical(); + if (lline.firstBufferLine === bline) { + lline.forEachMarker((m) => { mm.push(m as Marker);}); + } + } + return mm; + } + private _nullCell: ICellData = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); private _whitespaceCell: ICellData = CellData.fromCharData([0, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_WIDTH, WHITESPACE_CELL_CODE]); private _cols: number; private _rows: number; private _isClearing: boolean = false; - private _memoryCleanupQueue: InstanceType; - private _memoryCleanupPosition = 0; private readonly _stringCache: BufferLineStringCache; constructor( @@ -67,9 +86,28 @@ export class Buffer extends Disposable implements IBuffer { this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); - this._memoryCleanupQueue = new IdleTaskQueue(this._logService); - this._register(toDisposable(() => this._memoryCleanupQueue.clear())); - this._register(toDisposable(() => this.clearAllMarkers())); + + this.lines.onTrim(amount => { + for (let i = 0; i < amount; i++) { + this.clearMarkers(i); + } + const first = amount < this.lines.length && this.lines.get(amount); + if (first instanceof BufferLine && first.isWrapped) { + const prev = first.getPreviousLine(); + prev && first.asUnwrapped(prev); + } + if (first instanceof BufferLine && first._voffset < 0 && amount > 0) { + const line0 = this.lines.get(0); + if (line0 instanceof BufferLine && line0._voffset >= 0) { + first._voffset = line0._voffset + amount; + } + } + }); + this.lines.onDelete(event => { + for (let i = event.amount; --i >= 0; ) { + this.clearMarkers(event.index + i); + } + }); this._stringCache = this._register(new BufferLineStringCache()); } @@ -99,8 +137,16 @@ export class Buffer extends Disposable implements IBuffer { return this._whitespaceCell; } - public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped); + /** + * Get an empty unwrapped line. + * @param attr Only used for the background color. + */ + public getBlankLine( + attr: IAttributeData, + logicalLine: LogicalLine = new LogicalLine() + ): IBufferLine { + logicalLine.backgroundColor = attr.bg & ~0xFC000000; + return new BufferLine(this._stringCache, this._cols, logicalLine); } public get hasScrollback(): boolean { @@ -113,6 +159,20 @@ export class Buffer extends Disposable implements IBuffer { return (relativeY >= 0 && relativeY < this._rows); } + public lineNumberOf(line: BufferLine): number { + const nlines = this.lines.length; + const line0 = nlines > 0 && this.lines.get(0); + if (line0 instanceof BufferLine) { + if (line._voffset < 0 || line0._voffset < 0) { + for (let i = 0; i < nlines; i++) { + (this.lines.get(i) as BufferLine)._voffset = i; + } + } + return line._voffset - line0._voffset; + } + return -1; + } + /** * Gets the correct buffer length based on the rows provided, the terminal's * scrollback and whether this buffer is flagged to have scrollback or not. @@ -128,6 +188,18 @@ export class Buffer extends Disposable implements IBuffer { return correctBufferLength > MAX_BUFFER_SIZE ? MAX_BUFFER_SIZE : correctBufferLength; } + public setWrapped(absrow: number, value: boolean): void { + const line = this.lines.get(absrow); + 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); + } + } + /** * Fills the buffer's viewport with blank lines. */ @@ -142,7 +214,7 @@ export class Buffer extends Disposable implements IBuffer { } /** - * Clears the buffer to it's initial state, discarding all previous data. + * Clears the buffer to its initial state, discarding all previous data. */ public clear(): void { this._stringCache.clear(); @@ -162,77 +234,41 @@ export class Buffer extends Disposable implements IBuffer { * @param newRows The new number of rows. */ public resize(newCols: number, newRows: number): void { - // store reference to null cell with default attrs - const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); this._stringCache.clear(); - // 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. const newMaxLength = this._getCorrectBufferLength(newRows); if (newMaxLength > this.lines.maxLength) { this.lines.maxLength = newMaxLength; } - - // if (this._cols > newCols) { - // console.log('increase!'); - // } - - // The following adjustments should only happen if the buffer has been - // initialized/filled. - if (this.lines.length > 0) { - // Deal with columns increasing (reducing needs to happen after reflow) - if (this._cols < newCols) { - for (let i = 0; i < this.lines.length; i++) { - // +boolean for fast 0 or 1 conversion - dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell); - } + const ybaseOld = this.ybase; + const ydispOld = this.ydisp; + if (newRows < this._rows || newCols < this._cols) { + const minHeight = Math.max(this.savedY, this.ybase + this.y) + 1; + while (this.lines.length > minHeight && this.lines.get(this.lines.length - 1)?.logical().isEmpty()) { + this.lines.pop(); } + } - // Resize rows in both directions as needed - let addToY = 0; - if (this._rows < newRows) { - for (let y = this._rows; y < newRows; y++) { - if (this.lines.length < newRows + this.ybase) { - if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { - // Just add the new missing rows on Windows as conpty reprints the screen with it's - // view of the world. Once a line enters scrollback for conpty it remains there - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); - } else { - if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { - // There is room above the buffer and there are no empty elements below the line, - // scroll up - this.ybase--; - addToY++; - if (this.ydisp > 0) { - // Viewport is at the top of the buffer, must increase downwards - this.ydisp--; - } - } else { - // Add a blank line if there is no buffer left at the top to scroll to, or if there - // are blank lines after the cursor - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); - } - } - } - } - } else { // (this._rows >= newRows) - for (let y = this._rows; y > newRows; y--) { - if (this.lines.length > newRows + this.ybase) { - if (this.lines.length > this.ybase + this.y + 1) { - // The line is a blank line below the cursor, remove it - this.lines.pop(); - } else { - // The line is the cursor, scroll down - this.ybase++; - this.ydisp++; - } + if (this._cols !== newCols) { + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + const line = this.lines.get(i) as BufferLine; + line.length = newCols; + const logical = line.logical(); + if (! line.isWrapped) { + if (line.nextBufferLine || logical.length > newCols) { + logical.reflowNeeded = true; + this.lastReflowNeeded = Math.max(i, this.lastReflowNeeded); } } } + } + // The following adjustments should only happen if the buffer has been + // initialized/filled. + if (this.lines.length > 0) { // Reduce max length if needed after adjustments, this is done after as it // would otherwise cut data from the bottom of the buffer. if (newMaxLength < this.lines.maxLength) { @@ -250,9 +286,6 @@ export class Buffer extends Disposable implements IBuffer { // Make sure that the cursor stays on screen this.x = Math.min(this.x, newCols - 1); this.y = Math.min(this.y, newRows - 1); - if (addToY) { - this.y += addToY; - } this.savedX = Math.min(this.savedX, newCols - 1); this.scrollTop = 0; @@ -260,56 +293,48 @@ export class Buffer extends Disposable implements IBuffer { this.scrollBottom = newRows - 1; - if (this._isReflowEnabled) { - this._reflow(newCols, newRows); - - // Trim the end of the line off if cols shrunk - if (this._cols > newCols) { - for (let i = 0; i < this.lines.length; i++) { - // +boolean for fast 0 or 1 conversion - dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell); - } - } - } - + const lazyReflow = false; // FUTURE - change to true? + const reflowNow = this._isReflowEnabled && this._cols !== newCols && ! lazyReflow; + this._cols = newCols; + this._rows = newRows; + this.reflowRegion(reflowNow ? 0 : this.ydisp, this.lines.length, + reflowNow? -1 : newRows); this._cols = newCols; this._rows = newRows; + let ypos = Math.max(this.savedY, this.ybase + this.y) + 1; + if (this._optionsService.rawOptions.windowsPty.backend !== undefined + || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { + // Just add the new missing rows on Windows as conpty reprints + // the screen with its view of the world. + // Once a line enters scrollback for conpty it remains there + ypos = Math.max(newRows + this.ybase, ypos); + } else { + ypos = Math.max(newRows, ypos); + } + while (ypos > this.lines.length + && this.lines.length < this.lines.maxLength) { + // Add an extra row at the bottom of the viewport + this.lines.push(new BufferLine(this._stringCache, newCols)); + } // Ensure the cursor position invariant: ybase + y must be within buffer bounds // This can be violated during reflow or when shrinking rows - if (this.lines.length > 0) { - const maxY = Math.max(0, this.lines.length - this.ybase - 1); - this.y = Math.min(this.y, maxY); + if (this.y < 0) { this.y = 0; } // sanity check shouldn't happen + if (this.ybase >= this.lines.length) { + this.ybase = this.lines.length - 1; this.y = 0; } - - 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()); + const newHome = Math.max(0, this.lines.length - newRows); + this.y += this.ybase - newHome; + this.ybase = newHome; + if (this.savedY < 0 || this.savedY >= this.lines.length) { + this.savedY = 0; } - } - 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; + // Not sure this is the correct approach: It seems we should adjust ydisp + // depending on reflow/trimming of previous lines, like with do for ybase. + // (The logic in _reflowRegion does handle the necessary updates.) + // However, the following is what the testsuite currently expects. --PB + this.ydisp = ybaseOld === ydispOld ? this.ybase : ydispOld; } private get _isReflowEnabled(): boolean { @@ -320,223 +345,147 @@ export class Buffer extends Disposable implements IBuffer { return this._hasScrollback; } - private _reflow(newCols: number, newRows: number): void { - if (this._cols === newCols) { + public reflowRegion(startRow: number, endRow: number, maxRows: number): void { + if (startRow > this.lastReflowNeeded) { return; } - - // Iterate through rows, ignore the last one as it cannot be wrapped - if (newCols > this._cols) { - this._reflowLarger(newCols, newRows); - } else { - this._reflowSmaller(newCols, newRows); + if (endRow >= this.lastReflowNeeded) { + this.lastReflowNeeded = startRow; } - } - - 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); - if (toRemove.length > 0) { - const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); - reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); - this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved); + const newCols = this._cols; + while (startRow > 0 && this.lines.get(startRow)?.isWrapped) { + startRow--; + if (maxRows >= 0) { maxRows++; } } - } - - private _reflowLargerAdjustViewport(newCols: number, newRows: number, countRemoved: number): void { - const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); - // Adjust viewport based on number of items removed - let viewportAdjustments = countRemoved; - while (viewportAdjustments-- > 0) { - if (this.ybase === 0) { - if (this.y > 0) { - this.y--; + const newLines: BufferLine[] = []; + const yDispOld = this.ydisp; + const yBaseOld = this.ybase; + const yAbsOld = yBaseOld + this.y; + let yAbs = yAbsOld; + const ySavedOld = this.savedY; + let ySaved = ySavedOld; + if (! reflowCursorLine && yAbs >= 0 && yAbs < this.lines.length) { + const cursorLine = this.lines.get(yAbsOld) as BufferLine; + cursorLine.logical().reflowNeeded = false; + } + let deltaSoFar = 0; + for (let row = startRow; row < endRow;) { + if (maxRows >= 0 && newLines.length > maxRows) { + endRow = row; + break; + } + const line = this.lines.get(row) as BufferLine; + newLines.push(line); + const logical = line.logical(); + if (line === logical.firstBufferLine && logical.reflowNeeded) { + let curLine: BufferLine = line; + + let logicalX; + let logicalSavedX = this.savedX; + let oldWrapCount = 0; // number of following wrapped lines + let nextLine = curLine; + for (; ; oldWrapCount++) { + if (yAbsOld === row + oldWrapCount) { + logicalX = nextLine.startColumn + this.x; + } + if (ySavedOld === row + oldWrapCount) { + logicalSavedX = nextLine.startColumn + this.savedX; + } + if (! nextLine.nextBufferLine || row + oldWrapCount + 1 >= endRow) { + break; + } + nextLine = nextLine.nextBufferLine; } - if (this.lines.length < newRows) { - // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + const lineRow = row; + row++; + const newWrapStart = newLines.length; + logical.reflowNeeded = false; + let startCol = 0; + for (;;) { + const endCol = logical.charStart(startCol + newCols); + if (endCol >= logical.length) { + curLine.nextBufferLine = undefined; + curLine.startColumn = startCol; + break; + } + const nextLine = row < endRow && this.lines.get(row); + let newLine; + if (nextLine && nextLine.isWrapped) { + newLine = nextLine as BufferLine; + newLine.length = newCols; + row++; + } else { + newLine = new BufferLine(this._stringCache, newCols, logical); + } + newLines.push(newLine); + newLine.startColumn = endCol; + startCol = endCol; + curLine.nextBufferLine = newLine; + curLine = newLine; } - } else { - if (this.ydisp === this.ybase) { - this.ydisp--; + while (row < endRow && this.lines.get(row)!.isWrapped) { + row++; } - this.ybase--; - } - } - this.savedY = Math.max(this.savedY - countRemoved, 0); - } - - 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 = []; - let countToInsert = 0; - // Go backwards as many lines may be trimmed and this will avoid considering them - for (let y = this.lines.length - 1; y >= 0; y--) { - // Check whether this line is a problem - let nextLine = this.lines.get(y) as BufferLine; - if (!nextLine || !nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) { - continue; - } - - // Gather wrapped lines and adjust y to be the starting line - const wrappedLines: BufferLine[] = [nextLine]; - while (nextLine.isWrapped && y > 0) { - nextLine = this.lines.get(--y) as BufferLine; - wrappedLines.unshift(nextLine); - } - - if (!reflowCursorLine) { - // If these lines contain the cursor don't touch them, the program will handle fixing up - // wrapped lines with the cursor - const absoluteY = this.ybase + this.y; - if (absoluteY >= y && absoluteY < y + wrappedLines.length) { - continue; + const newWrapCount = newLines.length - newWrapStart; + if (yBaseOld >= lineRow && yBaseOld <= lineRow + oldWrapCount) { + this.ybase = lineRow + deltaSoFar + + Math.min(yBaseOld - lineRow, newWrapCount); } - } - - const lastLineLength = wrappedLines[wrappedLines.length - 1].getTrimmedLength(); - const destLineLengths = reflowSmallerGetNewLineLengths(wrappedLines, this._cols, newCols); - const linesToAdd = destLineLengths.length - wrappedLines.length; - let trimmedLines: number; - if (this.ybase === 0 && this.y !== this.lines.length - 1) { - // If the top section of the buffer is not yet filled - trimmedLines = Math.max(0, this.y - this.lines.maxLength + linesToAdd); - } else { - trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd); - } - - // 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. - // using this we can iterate through the list forwards - start: y + wrappedLines.length + countToInsert, - newLines - }); - countToInsert += newLines.length; - } - 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; + if (yDispOld >= lineRow && yDispOld <= lineRow + oldWrapCount) { + this.ydisp = lineRow + deltaSoFar + + Math.min(yDispOld - lineRow, newWrapCount); } - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); - destCol -= cellsToCopy; - if (destCol === 0) { - destLineIndex--; - destCol = destLineLengths[destLineIndex]; + if (logicalX !== undefined) { // update cursor x and y + let i = newWrapStart; + while (i < newLines.length && newLines[i].startColumn <= logicalX) { i++; } + yAbs = startRow + i - 1 + deltaSoFar; + this.x = logicalX - newLines[i-1].startColumn; } - srcCol -= cellsToCopy; - if (srcCol === 0) { - srcLineIndex--; - const wrappedLinesIndex = Math.max(srcLineIndex, 0); - srcCol = getWrappedLineTrimmedLength(wrappedLines, wrappedLinesIndex, this._cols); + if (logicalSavedX !== undefined) { // update cursor savedX and savedY + let i = newWrapStart; + while (i < newLines.length && newLines[i].startColumn <= logicalSavedX) { i++; } + ySaved = startRow + i - 1 + deltaSoFar; + this.savedX = logicalSavedX - newLines[i-1].startColumn; } - } - - // 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); + deltaSoFar += newWrapCount - oldWrapCount; + } else { + if (row === yBaseOld) { this.ybase = yBaseOld + deltaSoFar; } + if (row === yDispOld) { this.ydisp = yDispOld + deltaSoFar; } + if (row === yAbsOld) { + yAbs += deltaSoFar; } - } - - // Adjust viewport as needed - let viewportAdjustments = linesToAdd - trimmedLines; - while (viewportAdjustments-- > 0) { - if (this.ybase === 0) { - if (this.y < newRows - 1) { - this.y++; - this.lines.pop(); - } else { - this.ybase++; - this.ydisp++; - } - } else { - // Ensure ybase does not exceed its maximum value - if (this.ybase < Math.min(this.lines.maxLength, this.lines.length + countToInsert) - newRows) { - if (this.ybase === this.ydisp) { - this.ydisp++; - } - this.ybase++; - } + if (row === ySavedOld) { + ySaved += deltaSoFar; } + row++; } - this.savedY = Math.min(this.savedY + linesToAdd, this.ybase + newRows - 1); } - - // Rearrange lines in the buffer if there are any insertions, this is done at the end rather - // than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many - // costly calls to CircularList.splice. - if (toInsert.length > 0) { - // Record buffer insert events and then play them back backwards so that the indexes are - // correct - const insertEvents: IInsertEvent[] = []; - - // Record original lines so they don't get overridden when we rearrange the list - const originalLines: BufferLine[] = []; - for (let i = 0; i < this.lines.length; i++) { - originalLines.push(this.lines.get(i) as BufferLine); - } - const originalLinesLength = this.lines.length; - - let originalLineIndex = originalLinesLength - 1; - let nextToInsertIndex = 0; - let nextToInsert = toInsert[nextToInsertIndex]; - this.lines.length = Math.min(this.lines.maxLength, this.lines.length + countToInsert); - let countInsertedSoFar = 0; - for (let i = Math.min(this.lines.maxLength - 1, originalLinesLength + countToInsert - 1); i >= 0; i--) { - if (nextToInsert && nextToInsert.start > originalLineIndex + countInsertedSoFar) { - // Insert extra lines here, adjusting i as needed - for (let nextI = nextToInsert.newLines.length - 1; nextI >= 0; nextI--) { - this.lines.set(i--, nextToInsert.newLines[nextI]); - } - i++; - - // Create insert events for later - insertEvents.push({ - index: originalLineIndex + 1, - amount: nextToInsert.newLines.length - }); - - countInsertedSoFar += nextToInsert.newLines.length; - nextToInsert = toInsert[++nextToInsertIndex]; - } else { - this.lines.set(i, originalLines[originalLineIndex--]); - } + if (deltaSoFar !== 0) { + if (yAbsOld >= endRow) { yAbs += deltaSoFar; } + if (ySavedOld >= endRow) { ySaved += deltaSoFar; } + if (deltaSoFar > 0) { + if (yBaseOld >= endRow) { this.ybase = yBaseOld + deltaSoFar; } + if (yDispOld >= endRow) { this.ydisp = yDispOld + deltaSoFar; } } - - // Update markers - let insertCountEmitted = 0; - for (let i = insertEvents.length - 1; i >= 0; i--) { - insertEvents[i].index += insertCountEmitted; - this.lines.onInsertEmitter.fire(insertEvents[i]); - insertCountEmitted += insertEvents[i].amount; - } - const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); - if (amountToTrim > 0) { - this.lines.onTrimEmitter.fire(amountToTrim); + } + const untrimmedLength = this.lines.length + deltaSoFar; + this.lines.spliceItems(startRow, endRow - startRow, newLines, true); + const trimmedLength = this.lines.length; + const trimmedCount = untrimmedLength - trimmedLength; + yAbs = Math.max(0, yAbs - trimmedCount); + ySaved = Math.max(0, ySaved - trimmedCount); + this.ybase = Math.max(0, this.ybase - trimmedCount); + this.ydisp = Math.max(0, this.ydisp - trimmedCount); + this.y = yAbs - this.ybase; + this.savedY = ySaved; + if (deltaSoFar !== 0) { + const nlines = this.lines.length; + let prevOffset = startRow <= 0 ? -1 + : (this.lines.get(startRow - 1) as BufferLine)._voffset; + if (prevOffset < 0) { startRow = 0; } + for (let row = startRow; row < nlines; row++) { + (this.lines.get(row) as BufferLine)._voffset = ++prevOffset; } } } @@ -618,40 +567,39 @@ export class Buffer extends Disposable implements IBuffer { */ public clearMarkers(y: number): void { this._isClearing = true; - for (let i = 0; i < this.markers.length; i++) { - if (this.markers[i].line === y) { - this.markers[i].dispose(); - this.markers.splice(i--, 1); - } - } + (this.lines.get(y) as BufferLine).clearMarkers(); this._isClearing = false; } /** * Clears markers on all lines + * Must be called before removing lines from Buffer. + * Only used for the alt buffer, which should be small. */ public clearAllMarkers(): void { this._isClearing = true; - for (let i = 0; i < this.markers.length; i++) { - this.markers[i].dispose(); + const nlines = this.lines.length; + for (let i = 0; i < nlines; i++) { + this.clearMarkers(i); } - this.markers.length = 0; this._isClearing = false; } - public addMarker(y: number): Marker { - const marker = new Marker(y); - this.markers.push(marker); - marker.register(this.lines.onTrim(amount => { - marker.line -= amount; - // The marker should be disposed when the line is trimmed from the buffer - if (marker.line < 0) { - marker.dispose(); + public addMarker(y: number, x?: number, m?: Marker): Marker { + const bline = this.lines.get(y) as BufferLine; + if (bline._voffset < 0) { + const line0offset = (this.lines.get(0) as BufferLine)._voffset; + if (line0offset >= 0) { + bline._voffset = y + line0offset; } - })); + } + const marker = m ?? new Marker(); + marker.addToLine(this, bline, x ?? bline.startColumn); + marker.register(this.lines.onInsert(event => { - if (marker.line >= event.index) { - marker.line += event.amount; + const line = marker._lineData; + if (marker.line >= event.index && line instanceof BufferLine && line._voffset >= 0) { + line._voffset += event.amount; } })); marker.register(this.lines.onDelete(event => { @@ -659,19 +607,13 @@ export class Buffer extends Disposable implements IBuffer { if (marker.line >= event.index && marker.line < event.index + event.amount) { marker.dispose(); } - + const line = marker._lineData; // Shift the marker if it's after the deleted range - if (marker.line > event.index) { - marker.line -= event.amount; + if (marker.line > event.index && line instanceof BufferLine && line._voffset >= 0) { + line._voffset = Math.max(-1, line._voffset - event.amount); } })); - marker.register(marker.onDispose(() => this._removeMarker(marker))); - return marker; - } - private _removeMarker(marker: Marker): void { - if (!this._isClearing) { - this.markers.splice(this.markers.indexOf(marker), 1); - } + return marker; } } diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 3aec288723..98f7bd781e 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 { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { CellData } from 'common/buffer/CellData'; import { CharData, IBufferLine, ICellData } from '../Types'; @@ -15,12 +15,21 @@ const TEST_STRING_CACHE = new BufferLineStringCache(); class TestBufferLine extends BufferLine { - constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { - super(TEST_STRING_CACHE, cols, fillCellData, isWrapped); - } - - public get combined(): {[index: number]: string} { - return this._combined; + constructor(cols: number, fillCellData?: CellData, isWrapped: boolean = false) { + const lline = new LogicalLine(); + super(TEST_STRING_CACHE, cols, lline); + if (isWrapped) { + const prevLine = new BufferLine(TEST_STRING_CACHE, 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 get cachedString(): string | undefined { @@ -262,18 +271,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)); @@ -281,11 +278,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 @@ -296,8 +292,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 { @@ -322,15 +316,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 { @@ -789,13 +781,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 be200f1127..6e8e46b970 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -3,10 +3,11 @@ * @license MIT */ -import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types'; +import { CharData, IAttributeData, IBufferLine, ILogicalLine, ICellData, IExtendedAttrs, IMarker } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; -import { 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 { Marker } from 'common/buffer/Marker'; +import { Attributes, BgFlags, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; // Buffer memory layout: @@ -22,6 +23,18 @@ 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. * @@ -38,8 +51,7 @@ 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(); export interface IBufferLineStringCacheEntry { @@ -54,6 +66,270 @@ export interface IBufferLineStringCache { touch?(): void; } +/* + * The data "model" of a line ignoring line wrapping. + */ +export class LogicalLine implements ILogicalLine { + /** + * @internal + */ + public _data: Uint32Array; + /** + * @internal + */ + public _combined: {[index: LogicalColumn]: string} = {}; + /** + * @internal + */ + public _firstMarker: Marker | undefined; + /** + * @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 * Constants.CELL_INDICIES)) { + this._data = data; + } + + public isEmpty(): boolean { + return this.length === 0 && ! this._firstMarker; + } + + public forEachMarker(callback: (marker: IMarker) => void): void { + for (let m= this._firstMarker; m; m = m._nextMarker) { + callback(m); + } + } + + /** + * @internal + */ + 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); + data.set(this._data); + this._data = data; + } + } + + public getWidth(index: LogicalColumn): number { + return index >= this.length ? NULL_CELL_WIDTH + : this._data[index * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES; + 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; + } + + /** Returns the string content of the cell. */ + public getString(index: number): string { + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + if (content & Content.IS_COMBINED_MASK) { + return this._combined[index]; + } + if (content & Content.CODEPOINT_MASK) { + return stringFromCodePoint(content & Content.CODEPOINT_MASK); + } + // return empty string for empty cells + return ''; + } + + /** Get state of protected flag. */ + public isProtected(index: number): number { + return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; + } + + /** + * 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 { + 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) { + this.resizeData(index + 1); + for (let i = this.length; i < index; i++) { + this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] = NULL_CELL_WIDTH << Content.WIDTH_SHIFT; + this._data[i * Constants.CELL_INDICIES + Cell.FG] = 0; + this._data[i * Constants.CELL_INDICIES + Cell.BG] = this.backgroundColor; + } + this.length = index + 1; + } + 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; + } + + /** + * 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 + */ + public trimLength(): void { + let index = this.length; + while (index > 0) { + index--; + const content = this._data[index * Constants.CELL_INDICIES + 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 + } + } + + /** + * Warning - does not invalidate string cache. + */ + 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); + const dstIndex = dstCol + cell; + const content = $workCell.content & (Content.CODEPOINT_MASK|Content.IS_COMBINED_MASK); + this.setCellFromCodepoint(dstIndex, content, $workCell.getWidth(), $workCell); + if (content & Content.IS_COMBINED_MASK) { + this._combined[dstIndex] = $workCell.combinedData; + } + } + } + + /** + * 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 * 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; + 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. * @@ -63,48 +339,73 @@ export interface IBufferLineStringCache { * 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} = {}; + private _logicalLine: LogicalLine; + public logical(): LogicalLine { return this._logicalLine; } + public nextBufferLine: BufferLine | undefined; protected _stringCacheEntryRef: WeakRef | undefined; + public _voffset: number = -1; + + /** Number of logical columns in previous rows. + * Also: logical column number (column number assuming infinitely-wide + * 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; - constructor( - protected readonly _stringCache: IBufferLineStringCache, + /** + * Last LogicalColumn of this BufferLine. + * @internal + */ + public get validEnd(): LogicalColumn { + return this.nextBufferLine ? this.nextBufferLine.startColumn : this._logicalLine.length; + } + + constructor(protected readonly _stringCache: IBufferLineStringCache, cols: number, - fillCellData?: ICellData, - public isWrapped: boolean = false + logicalLine = new LogicalLine(cols) ) { - this._data = new Uint32Array(cols * Constants.CELL_INDICIES); - const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); - for (let i = 0; i < cols; ++i) { - this.setCell(i, cell); - } + this._logicalLine = logicalLine; this.length = cols; + 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 * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; return [ - this._data[index * Constants.CELL_INDICIES + Cell.FG], + lline._data[lindex * Constants.CELL_INDICIES + 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 ]; } @@ -114,14 +415,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { - this._invalidateStringCache(); - this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; - if (value[CHAR_DATA_CHAR_INDEX].length > 1) { - this._combined[index] = value[1]; - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); - } else { - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); - } + this.setCell(index, CellData.fromCharData(value)); } /** @@ -129,22 +423,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 * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + Cell.CONTENT] & Content.WIDTH_MASK; + return this.getWidth(index); } /** Get FG cell component. */ public getFg(index: number): number { - return this._data[index * Constants.CELL_INDICIES + Cell.FG]; + const lline = this._logicalLine; + const lcolumn = index + this.startColumn; + return lcolumn >= this.validEnd ? 0 : lline._data[lcolumn * Constants.CELL_INDICIES + Cell.FG]; } /** Get BG cell component. */ public getBg(index: number): number { - return this._data[index * Constants.CELL_INDICIES + Cell.BG]; + index += this.startColumn; + const lline = this._logicalLine; + return index > lline.length ? lline.backgroundColor + : lline._data[index * Constants.CELL_INDICIES + Cell.BG]; } /** @@ -153,7 +454,12 @@ export class BufferLine implements IBufferLine { * from real empty cells. */ public hasContent(index: number): number { - return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; + index += this.startColumn; + if (index >= this.validEnd) { + return 0; + } + const lline = this._logicalLine; + return lline._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** @@ -161,24 +467,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 * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ public getString(index: number): string { - const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; + 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 this._combined[index]; + return lline._combined[lcolumn]; } if (content & Content.CODEPOINT_MASK) { return stringFromCodePoint(content & Content.CODEPOINT_MASK); @@ -189,7 +511,10 @@ export class BufferLine implements IBufferLine { /** Get state of protected flag. */ public isProtected(index: number): number { - return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; + 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; } /** @@ -197,17 +522,20 @@ 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 * Constants.CELL_INDICIES; - 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); } /** @@ -215,15 +543,12 @@ export class BufferLine implements IBufferLine { */ public setCell(index: number, cell: ICellData): void { this._invalidateStringCache(); + // 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 * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content; - this._data[index * Constants.CELL_INDICIES + Cell.FG] = cell.fg; - this._data[index * Constants.CELL_INDICIES + Cell.BG] = cell.bg; } /** @@ -233,12 +558,8 @@ export class BufferLine implements IBufferLine { */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { this._invalidateStringCache(); - 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; + this._logicalLine.setCellFromCodepoint(index + this.startColumn, + codePoint, width, attrs); } /** @@ -249,16 +570,24 @@ export class BufferLine implements IBufferLine { */ public addCodepointToCell(index: number, codePoint: number, width: number): void { this._invalidateStringCache(); - let content = this._data[index * Constants.CELL_INDICIES + 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 * Constants.CELL_INDICIES + 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 { @@ -271,7 +600,7 @@ export class BufferLine implements IBufferLine { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; } - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content; + lline._data[lcolumn * Constants.CELL_INDICIES + Cell.CONTENT] = content; } public insertCells(pos: number, n: number, fillCellData: ICellData): void { @@ -363,69 +692,75 @@ export class BufferLine implements IBufferLine { } /** - * Resize BufferLine to `cols` filling excess cells with `fillCellData`. + * @internal + */ + public clearMarkers(): void { + const lline = this._logicalLine; + const startColumn = this.startColumn; + const endColumn = this.nextBufferLine ? this.nextBufferLine.startColumn : Infinity; + for (let m = lline._firstMarker; m; m = m._nextMarker) { + if (m._startColumn >= startColumn && m._startColumn < endColumn) { + m.dispose(); + } + } + } + + /** + * Resize to `cols` filling excess cells with `fillCellData`. * The underlying array buffer will not change if there is still enough space * to hold the new buffer line data. * Returns a boolean indicating, whether a `cleanupMemory` call would free - * excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD). + * excess memory (true after shrinking > Constants.Constants.CLEANUP_THRESHOLD). + * Assumes single unwrapped line. + * @deprecated only used in tests */ public resize(cols: number, fillCellData: ICellData): boolean { this._invalidateStringCache(); + 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 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return logical._data.length * 4 * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } const uint32Cells = cols * Constants.CELL_INDICIES; 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 * Constants.CELL_INDICIES); // 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 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < logical._data.buffer.byteLength; } /** * 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. + * memory by a factor of Constants.Constants.CLEANUP_THRESHOLD. * Returns 0 or 1 indicating whether a cleanup happened. */ public cleanupMemory(): number { - if (this._data.length * 4 * Constants.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(Constants.CLEANUP_THRESHOLD); } /** fill a line with fillCharData */ @@ -440,98 +775,176 @@ 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 { - this._invalidateStringCache(); - 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(this._stringCache, 0, undefined, false); - 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]; + 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 * 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; + return i - startColumn; + } } - newLine.isWrapped = this.isWrapped; - return newLine; + return startColumn; } - public getTrimmedLength(): number { - for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) { - return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT); - } + public getNoBgTrimmedLength(): number { + if (this._logicalLine.backgroundColor) { + return this.length; } - return 0; + return this.getTrimmedLength(true); } - public getNoBgTrimmedLength(): number { - for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK)) { - return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT); + public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { + this._invalidateStringCache(); + this._logicalLine.copyCellsFrom(src._logicalLine, srcCol + src.startColumn, + destCol + this.startColumn, length, applyInReverse); + } + + 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; } - return 0; } - public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { + public eraseRight(index: BufferColumn): void { this._invalidateStringCache(); - const srcData = src._data; - if (applyInReverse) { - for (let cell = length - 1; cell >= 0; cell--) { - for (let i = 0; i < Constants.CELL_INDICIES; i++) { - this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i]; - } - if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; + 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 { - for (let cell = 0; cell < length; cell++) { - for (let i = 0; i < Constants.CELL_INDICIES; i++) { - this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i]; - } - if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) { - this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; - } + if (lineEnd < lline.length) { + lline.length = lineEnd; } } + } + + 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 * Constants.CELL_INDICIES + Cell.CONTENT] = 0; + newData[i * Constants.CELL_INDICIES + Cell.FG] = 0; + newData[i * Constants.CELL_INDICIES + 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]; + } + } + */ + let prevLastMarker; + for (let m = logicalLine._firstMarker; m; m = m._nextMarker) { + prevLastMarker = m; + } + let m = oldLogical._firstMarker; + if (prevLastMarker) prevLastMarker._nextMarker = m; + else logicalLine._firstMarker = m; + for (; m; m = m._nextMarker) { + m._startColumn += column; + } + oldLogical._firstMarker = undefined; + logicalLine.length = column + oldLogical.length; + previousLine.nextBufferLine = this; + for (let line: BufferLine | undefined = this; line; line = line.nextBufferLine) { + line.startColumn += column; + line._logicalLine = logicalLine; + } + return this; + + } - // 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]; + 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; + } + let prevMarker: Marker | undefined; // in oldLine marker list + let newMarkerLast: Marker | undefined; // in newLogical marker list + for (let m = oldLine._firstMarker; m; ) { + const oldNext = m._nextMarker; + if (m._startColumn >= oldStartColumn) { // move to new line + m._startColumn -= oldStartColumn; + if (prevMarker) { prevMarker._nextMarker = oldNext; } + else { oldLine._firstMarker = oldNext; } + m._nextMarker = undefined; + if (newMarkerLast) { newMarkerLast._nextMarker = m; } + else { newLogical._firstMarker = m; } + newMarkerLast = m; } + prevMarker = m; + m = oldNext; } + oldLine.length = oldStartColumn; + oldLine.trimLength(); + // FIXME truncate/resize + newLogical.backgroundColor = oldLine.backgroundColor; + return newLogical; } /** @@ -567,24 +980,18 @@ export class BufferLine implements IBufferLine { if (trimRight) { endCol = Math.min(endCol, this.getTrimmedLength()); } - if (outColumns) { - outColumns.length = 0; - } - let result = ''; - while (startCol < endCol) { - const content = 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; - result += chars; - if (outColumns) { - for (let i = 0; i < chars.length; ++i) { - outColumns.push(startCol); - } + 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; } - startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 - } - if (outColumns) { - outColumns.push(startCol); } if (isCanonicalRequest) { const cacheEntry = this._getStringCacheEntry(true)!; diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index d71186ddda..f5e2da47aa 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -6,7 +6,71 @@ import { assert } from 'chai'; import { BufferLine } from 'common/buffer/BufferLine'; import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; 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; +} const TEST_STRING_CACHE = new BufferLineStringCache(); @@ -66,7 +130,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(TEST_STRING_CACHE, 6, undefined, true); + const line2 = new BufferLine(TEST_STRING_CACHE, 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..2c4fda3f94 100644 --- a/src/common/buffer/BufferReflow.ts +++ b/src/common/buffer/BufferReflow.ts @@ -4,211 +4,12 @@ */ import { BufferLine } from 'common/buffer/BufferLine'; -import { CircularList } from 'common/CircularList'; -import { IBufferLine, ICellData } 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. - * @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; -} - -/** - * 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/Marker.ts b/src/common/buffer/Marker.ts index 59ad049b0d..2ff778896d 100644 --- a/src/common/buffer/Marker.ts +++ b/src/common/buffer/Marker.ts @@ -5,23 +5,62 @@ import { IDisposable, IMarker } from 'common/Types'; import { Emitter } from 'common/Event'; +import { Buffer } from 'common/buffer/Buffer'; +import { BufferLine, LogicalColumn } from 'common/buffer/BufferLine'; import { dispose } from 'common/Lifecycle'; export class Marker implements IMarker { + public payload?: IDisposable; + private _buffer: Buffer | undefined; + /** @internal */ + public _lineData: BufferLine | undefined; + /** + * @internal + */ + public _startColumn: LogicalColumn = -1; + /** + * @internal + */ + public _nextMarker: Marker | undefined; private static _nextId = 1; public isDisposed: boolean = false; private readonly _disposables: IDisposable[] = []; - private readonly _id: number = Marker._nextId++; + /** + * @deprecated + */ public get id(): number { return this._id; } + public addDisposable(o: T): T { + if (this.isDisposed) { + o.dispose(); + } else { + this._disposables.push(o); + } + return o; + } + private readonly _onDispose = this.register(new Emitter()); public readonly onDispose = this._onDispose.event; - constructor( - public line: number - ) { + public addToLine(buffer: Buffer, bline: BufferLine, startColumn: LogicalColumn): void { + this._buffer = buffer; + this._lineData = bline; + const lline = bline.logical(); + this._startColumn = startColumn; + this._nextMarker = lline._firstMarker; + lline._firstMarker = this; + } + + /** + * Get corresponding line number. + * + */ + public get line(): number { + return this._buffer && this._lineData + ? this._buffer.lineNumberOf(this._lineData) + : -1; } public dispose(): void { @@ -29,11 +68,34 @@ export class Marker implements IMarker { return; } this.isDisposed = true; - this.line = -1; // Emit before super.dispose such that dispose listeners get a change to react this._onDispose.fire(); dispose(this._disposables); this._disposables.length = 0; + this.removeMarker(); + this._buffer = undefined; + this._lineData = undefined; + this._startColumn = -1; + } + + public removeMarker(): void { + const bline = this._lineData; + if (! bline) { + return; + } + const lline = bline.logical(); + let prev: Marker | undefined; + for (let m = lline._firstMarker; m; ) { + const next = m._nextMarker; + if (m === this) { + if (prev) { prev._nextMarker = next; } + else { lline._firstMarker = next; } + break; + } + prev = m; + m = next; + } + this._nextMarker = undefined; } public register(disposable: T): T { diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 85dd68eec2..36793dc71b 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IAttributeData, ICircularList, IBufferLine, ICellData, IMarker, ICharset, IDisposable } from 'common/Types'; +import { IAttributeData, ICircularList, IBufferLine, ILogicalLine, ICellData, IMarker, ICharset, IDisposable } from 'common/Types'; import type { IEvent } from 'common/Event'; // BufferIndex denotes a position in the buffer: [rowIndex, colIndex] @@ -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; @@ -33,12 +53,13 @@ export interface IBuffer { getWrappedRangeForLine(y: number): { first: number, last: number }; nextStop(x?: number): number; prevStop(x?: number): number; - getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine; + getBlankLine(attr: IAttributeData, logicalLine?: ILogicalLine): IBufferLine; getNullCell(attr?: IAttributeData): ICellData; getWhitespaceCell(attr?: IAttributeData): ICellData; addMarker(y: number): IMarker; clearMarkers(y: number): void; clearAllMarkers(): void; + setWrapped(row: number, value: boolean): void; } export interface IBufferSet extends IDisposable { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 06ac6b10a2..8d8610e554 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -9,6 +9,7 @@ import { BufferSet } from 'common/buffer/BufferSet'; 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 enum BufferServiceConstants { MINIMUM_COLS = 2, // Less than 2 can mess with wide chars @@ -68,17 +69,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.isWrapped = 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.logical(); + } else { + lline = new LogicalLine(0); + } + const newLine = buffer.getBlankLine(eraseAttr, lline) as BufferLine; + 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. @@ -86,13 +91,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 @@ -114,7 +115,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 diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e244..3be3a7743d 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,15 +8,11 @@ import { DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService } from 'common/TestUtils.test'; function createFakeMarker(line: number): IMarker { - return Object.freeze(new class extends Disposable { - public readonly id = 1; - public readonly line = line; - public readonly isDisposed = false; - public readonly onDispose = new Emitter().event; - }()); + const bufferService = new MockBufferService(80, 30); + return bufferService.buffer.addMarker(line); } const fakeMarker: IMarker = createFakeMarker(1); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985e..4631bf2036 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -6,8 +6,8 @@ import { css } from 'common/Color'; import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; -import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, IBufferLine } from 'common/Types'; +import { Marker } from 'common/buffer/Marker'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; @@ -20,25 +20,33 @@ let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; - /** - * A list of all decorations, sorted by the marker's line value. This relies on the fact that - * while marker line values do change, they should all change by the same amount so this should - * never become out of order. - */ - private readonly _decorations: SortedList; - private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); public readonly onDecorationRemoved = this._onDecorationRemoved.event; - public get decorations(): IterableIterator { return this._decorations.values(); } + public get decorations(): IterableIterator { + const iterator = { + current: this._firstDecoration, + next: (): IteratorResult => { + const node = iterator.current; + if (node) { + iterator.current = node.nextDecoration; + return { done: false, value: node }; + } + return { done: true, value: undefined }; + }, + [Symbol.iterator]: () => { + return iterator; } + }; + return iterator; + } + private _firstDecoration: Decoration | undefined; + private _lastDecoration: Decoration | undefined; constructor(@ILogService private readonly _logService: ILogService) { super(); - this._decorations = new SortedList(e => e?.marker.line, this._logService); - this._register(toDisposable(() => this.reset())); } @@ -52,31 +60,52 @@ export class DecorationService extends Disposable implements IDecorationService const listener = decoration.onDispose(() => { listener.dispose(); if (decoration) { - if (this._decorations.delete(decoration)) { - this._onDecorationRemoved.fire(decoration); + // Remove from linked list + const previous = decoration.previousDecoration; + const next = decoration.nextDecoration; + if (previous) { + previous.nextDecoration = next; + } else { + this._firstDecoration = next; } + if (next) { + next.previousDecoration = previous; + } else { + this._lastDecoration = previous; + } + this._onDecorationRemoved.fire(decoration); markerDispose.dispose(); } }); - this._decorations.insert(decoration); + // insert decoration into linked list + decoration.previousDecoration = this._lastDecoration; + if (this._lastDecoration) { + this._lastDecoration.nextDecoration = decoration; + } else { + this._firstDecoration = decoration; + } + this._lastDecoration = decoration; this._onDecorationRegistered.fire(decoration); } return decoration; } public reset(): void { - for (const d of this._decorations.values()) { + for (let d = this._firstDecoration; d; d = d.nextDecoration) { d.dispose(); } - this._decorations.clear(); } + /** + * Only used in tests. + * @deprecated + */ public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { let xmin = 0; let xmax = 0; let ymin = 0; let ymax = 0; - for (const d of this._decorations.values()) { + for (const d of this.decorations) { ymin = d.marker.line; ymax = ymin + (d.options.height ?? 1); if (line < ymin || line >= ymax) { @@ -90,8 +119,23 @@ export class DecorationService extends Disposable implements IDecorationService } } + public forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, bline: IBufferLine): void { + const lline = bline.logical(); + x += bline.startColumn; + lline.forEachMarker((marker: IMarker) => { + const d = marker.payload; + if (d instanceof Decoration) { + const xmin = (marker as Marker)._startColumn; + const xmax = xmin + (d.options.width ?? 1); + if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { + callback(d); + } + } + }); + } + public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - for (const d of this._decorations.values()) { + for (let d = this._firstDecoration; d; d = d.nextDecoration) { $ymin = d.marker.line; $ymax = $ymin + (d.options.height ?? 1); if (line < $ymin || line >= $ymax) { @@ -106,9 +150,12 @@ export class DecorationService extends Disposable implements IDecorationService } } + class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + public nextDecoration: Decoration | undefined; + public previousDecoration: Decoration | undefined; public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; @@ -144,6 +191,8 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + if (options.x) { (this.marker as Marker)._startColumn += options.x; } + this.marker.payload = this; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc6..51cfc8840b 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -4,7 +4,7 @@ */ import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm'; -import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; +import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, IBufferLine, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import type { Emitter, IEvent } from 'common/Event'; @@ -390,6 +390,7 @@ export interface IDecorationService extends IDisposable { * instead of an iterator as it's typically used in hot code paths. */ forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void; + forEachDecorationAtCellLine(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void, lineData: IBufferLine): void; } export interface IInternalDecoration extends IDecoration { readonly options: IDecorationOptions; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1ce877b9a..c7bf124bf2 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -561,14 +561,18 @@ declare module '@xterm/xterm' { export interface IMarker extends IDisposableWithEvent { /** * A unique identifier for this marker. + * @deprecated */ readonly id: number; /** * The actual line index in the buffer at this point in time. This is set to * -1 if the marker has been disposed. + * This is an expensive operation. + * @deprecated */ readonly line: number; + payload?: IDisposable; } /**