/** * Copyright (c) 2018 The xterm.js authors. All rights reserved. * @license MIT */ import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; /** * buffer memory layout: * * | uint32_t | uint32_t | uint32_t | * | `content` | `FG` | `BG` | * | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) | */ /** typed array slots taken by one cell */ const CELL_SIZE = 3; /** * Cell member indices. * * Direct access: * `content = data[column * CELL_SIZE + Cell.CONTENT];` * `fg = data[column * CELL_SIZE + Cell.FG];` * `bg = data[column * CELL_SIZE + Cell.BG];` */ const enum Cell { CONTENT = 0, FG = 1, // currently simply holds all known attrs BG = 2 // currently unused } export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); // Work variables to avoid garbage collection let $startIndex = 0; /** Factor when to cleanup underlying array buffer after shrinking. */ const CLEANUP_THRESHOLD = 2; /** * Typed array based bufferline implementation. * * There are 2 ways to insert data into the cell buffer: * - `setCellFromCodepoint` + `addCodepointToCell` * Use these for data that is already UTF32. * 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). * * To retrieve data from the buffer use either one of the primitive methods * (if only one particular value is needed) or `loadCell`. For `loadCell` in a loop * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; public length: number; /** * Sailfish collapse support: marks this line as the start of a collapsible region. */ public isCollapseStart: boolean = false; /** * Sailfish collapse support: indicates if this collapse region is currently collapsed. */ public isCollapsed: boolean = false; /** * Sailfish collapse support: number of buffer lines hidden when this region is collapsed. */ public collapsedLineCount: number = 0; /** * Sailfish collapse support: unique identifier for the collapse region. */ public collapseRegionId: number = 0; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { this._data = new Uint32Array(cols * CELL_SIZE); const cell = fillCellData || CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); for (let i = 0; i < cols; ++i) { this.setCell(i, cell); } this.length = cols; } /** * Get cell data CharData. * @deprecated */ public get(index: number): CharData { const content = this._data[index * CELL_SIZE + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; return [ this._data[index * CELL_SIZE + Cell.FG], (content & Content.IS_COMBINED_MASK) ? this._combined[index] : (cp) ? stringFromCodePoint(cp) : '', content >> Content.WIDTH_SHIFT, (content & Content.IS_COMBINED_MASK) ? this._combined[index].charCodeAt(this._combined[index].length - 1) : cp ]; } /** * Set cell data from CharData. * @deprecated */ public set(index: number, value: CharData): void { this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } else { this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } } /** * primitive getters * use these when only one value is needed, otherwise use `loadCell` */ public getWidth(index: number): number { return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT; } /** Test whether content has width. */ public hasWidth(index: number): number { return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK; } /** Get FG cell component. */ public getFg(index: number): number { return this._data[index * CELL_SIZE + Cell.FG]; } /** Get BG cell component. */ public getBg(index: number): number { return this._data[index * CELL_SIZE + Cell.BG]; } /** * Test whether contains any chars. * Basically an empty has no content, but other cells might differ in FG/BG * from real empty cells. */ public hasContent(index: number): number { return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** * Get codepoint of the cell. * To be in line with `code` in CharData this either returns * a single UTF32 codepoint or the last codepoint of a combined string. */ public getCodePoint(index: number): number { const content = this._data[index * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { return this._combined[index].charCodeAt(this._combined[index].length - 1); } return content & Content.CODEPOINT_MASK; } /** Test whether the cell contains a combined string. */ public isCombined(index: number): number { return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ public getString(index: number): string { const content = this._data[index * CELL_SIZE + 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 * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED; } /** * Load data at `index` into `cell`. This is used to access cells in a way that's more friendly * to GC as it significantly reduced the amount of new objects/references needed. */ public loadCell(index: number, cell: ICellData): ICellData { $startIndex = index * CELL_SIZE; cell.content = this._data[$startIndex + Cell.CONTENT]; cell.fg = this._data[$startIndex + Cell.FG]; cell.bg = this._data[$startIndex + Cell.BG]; if (cell.content & Content.IS_COMBINED_MASK) { cell.combinedData = this._combined[index]; } if (cell.bg & BgFlags.HAS_EXTENDED) { cell.extended = this._extendedAttrs[index]!; } return cell; } /** * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { if (cell.content & Content.IS_COMBINED_MASK) { this._combined[index] = cell.combinedData; } if (cell.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = cell.extended; } this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content; this._data[index * CELL_SIZE + Cell.FG] = cell.fg; this._data[index * CELL_SIZE + Cell.BG] = cell.bg; } /** * Set cell data from input handler. * Since the input handler see the incoming chars as UTF32 codepoints, * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); this._data[index * CELL_SIZE + Cell.FG] = attrs.fg; this._data[index * CELL_SIZE + Cell.BG] = attrs.bg; } /** * Add a codepoint to a cell from input handler. * During input stage combining chars with a width of 0 follow and stack * onto a leading char. Since we already set the attrs * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { let content = this._data[index * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add this._combined[index] += 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); content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0 content |= Content.IS_COMBINED_MASK; } else { // 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 content = codePoint | (1 << Content.WIDTH_SHIFT); } } if (width) { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; } this._data[index * CELL_SIZE + Cell.CONTENT] = content; } public insertCells(pos: number, n: number, fillCellData: ICellData): void { pos %= this.length; // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char if (pos && this.getWidth(pos - 1) === 2) { this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); } if (n < this.length - pos) { const cell = new CellData(); for (let i = this.length - pos - n - 1; i >= 0; --i) { this.setCell(pos + n + i, this.loadCell(pos + i, cell)); } for (let i = 0; i < n; ++i) { this.setCell(pos + i, fillCellData); } } else { for (let i = pos; i < this.length; ++i) { this.setCell(i, fillCellData); } } // handle fullwidth at line end: reset last cell if it is first cell of a wide char if (this.getWidth(this.length - 1) === 2) { this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData); } } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { pos %= this.length; if (n < this.length - pos) { const cell = new CellData(); for (let i = 0; i < this.length - pos - n; ++i) { this.setCell(pos + i, this.loadCell(pos + n + i, cell)); } for (let i = this.length - n; i < this.length; ++i) { this.setCell(i, fillCellData); } } else { for (let i = pos; i < this.length; ++i) { this.setCell(i, fillCellData); } } // handle fullwidth at pos: // - reset pos-1 if wide char // - reset pos if width==0 (previous second cell of a wide char) if (pos && this.getWidth(pos - 1) === 2) { this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); } if (this.getWidth(pos) === 0 && !this.hasContent(pos)) { this.setCellFromCodepoint(pos, 0, 1, fillCellData); } } public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { this.setCellFromCodepoint(start - 1, 0, 1, fillCellData); } if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) { this.setCellFromCodepoint(end, 0, 1, fillCellData); } while (start < end && start < this.length) { if (!this.isProtected(start)) { this.setCell(start, fillCellData); } start++; } return; } // handle fullwidth at start: reset cell one to the left if start is second cell of a wide char if (start && this.getWidth(start - 1) === 2) { this.setCellFromCodepoint(start - 1, 0, 1, fillCellData); } // handle fullwidth at last cell + 1: reset to empty cell if it is second part of a wide char if (end < this.length && this.getWidth(end - 1) === 2) { this.setCellFromCodepoint(end, 0, 1, fillCellData); } while (start < end && start < this.length) { this.setCell(start++, fillCellData); } } /** * Resize BufferLine to `cols` filling excess cells with `fillCellData`. * The underlying array buffer will not change if there is still enough space * to hold the new buffer line data. * Returns a boolean indicating, whether a `cleanupMemory` call would free * excess memory (true after shrinking > CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { if (cols === this.length) { return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; } const uint32Cells = cols * CELL_SIZE; if (cols > this.length) { if (this._data.buffer.byteLength >= uint32Cells * 4) { // optimization: avoid alloc and data copy if buffer has enough room this._data = new Uint32Array(this._data.buffer, 0, uint32Cells); } else { // slow path: new alloc and full data copy const data = new Uint32Array(uint32Cells); data.set(this._data); this._data = data; } 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); // 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]; } } } this.length = cols; return uint32Cells * 4 * CLEANUP_THRESHOLD < this._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 CLEANUP_THRESHOLD. * Returns 0 or 1 indicating whether a cleanup happened. */ public cleanupMemory(): number { if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) { const data = new Uint32Array(this._data.length); data.set(this._data); this._data = data; return 1; } return 0; } /** fill a line with fillCharData */ public fill(fillCellData: ICellData, respectProtect: boolean = false): void { // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { for (let i = 0; i < this.length; ++i) { if (!this.isProtected(i)) { this.setCell(i, fillCellData); } } return; } this._combined = {}; this._extendedAttrs = {}; for (let i = 0; i < this.length; ++i) { this.setCell(i, fillCellData); } } /** alter to a full copy of line */ public copyFrom(line: BufferLine): void { if (this.length !== line.length) { this._data = new Uint32Array(line._data); } else { // use high speed copy if lengths are equal this._data.set(line._data); } this.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; // Sailfish collapse support this.isCollapseStart = line.isCollapseStart; this.isCollapsed = line.isCollapsed; this.collapsedLineCount = line.collapsedLineCount; this.collapseRegionId = line.collapseRegionId; } /** create a new clone */ public clone(): IBufferLine { const newLine = new BufferLine(0); newLine._data = new Uint32Array(this._data); newLine.length = this.length; for (const el in this._combined) { newLine._combined[el] = this._combined[el]; } for (const el in this._extendedAttrs) { newLine._extendedAttrs[el] = this._extendedAttrs[el]; } newLine.isWrapped = this.isWrapped; // Sailfish collapse support newLine.isCollapseStart = this.isCollapseStart; newLine.isCollapsed = this.isCollapsed; newLine.collapsedLineCount = this.collapsedLineCount; newLine.collapseRegionId = this.collapseRegionId; return newLine; } public getTrimmedLength(): number { for (let i = this.length - 1; i >= 0; --i) { if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) { return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); } } return 0; } public getNoBgTrimmedLength(): number { for (let i = this.length - 1; i >= 0; --i) { if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) { return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); } } return 0; } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { for (let i = 0; i < CELL_SIZE; i++) { this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; } if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; } } } else { for (let cell = 0; cell < length; cell++) { for (let i = 0; i < CELL_SIZE; i++) { this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; } if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; } } } // Move any combined data over as needed, FIXME: repeat for extended attrs const srcCombinedKeys = Object.keys(src._combined); for (let i = 0; i < srcCombinedKeys.length; i++) { const key = parseInt(srcCombinedKeys[i], 10); if (key >= srcCol) { this._combined[key - srcCol + destCol] = src._combined[key]; } } } /** * Translates the buffer line to a string. * * @param trimRight Whether to trim any empty cells on the right. * @param startCol The column to start the string (0-based inclusive). * @param endCol The column to end the string (0-based exclusive). * @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(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { startCol = startCol ?? 0; endCol = endCol ?? this.length; if (trimRight) { endCol = Math.min(endCol, this.getTrimmedLength()); } if (outColumns) { outColumns.length = 0; } let result = ''; while (startCol < endCol) { const content = this._data[startCol * CELL_SIZE + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; result += chars; if (outColumns) { for (let i = 0; i < chars.length; ++i) { outColumns.push(startCol); } } startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by at least 1 } if (outColumns) { outColumns.push(startCol); } return result; } }