/** * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ import { CircularList, IInsertEvent } from 'common/CircularList'; import { IBuffer, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult } from 'common/buffer/Types'; import { IBufferLine, ICellData, IAttributeData, ICharset } from 'common/Types'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_WIDTH, WHITESPACE_CELL_CODE, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from 'common/buffer/Constants'; import { reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths, getWrappedLineTrimmedLength } from 'common/buffer/BufferReflow'; import { Marker } from 'common/buffer/Marker'; import { IOptionsService, IBufferService } from 'common/services/Services'; import { DEFAULT_CHARSET } from 'common/data/Charsets'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 /** * This class represents a terminal buffer (an internal state of the terminal), where the * following information is stored (in high-level): * - text content of this particular buffer * - cursor position * - scroll position */ export class Buffer implements IBuffer { public lines: CircularList; public ydisp: number = 0; public ybase: number = 0; public y: number = 0; public x: number = 0; public scrollBottom: number; public scrollTop: number; // TODO: Type me public tabs: any; public savedY: number = 0; public savedX: number = 0; public savedCurAttrData = DEFAULT_ATTR_DATA.clone(); public savedCharset: ICharset | undefined = DEFAULT_CHARSET; public markers: Marker[] = []; 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; constructor( private _hasScrollback: boolean, private _optionsService: IOptionsService, private _bufferService: IBufferService ) { this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); } public getNullCell(attr?: IAttributeData): ICellData { if (attr) { this._nullCell.fg = attr.fg; this._nullCell.bg = attr.bg; this._nullCell.extended = attr.extended; } else { this._nullCell.fg = 0; this._nullCell.bg = 0; this._nullCell.extended = new ExtendedAttrs(); } return this._nullCell; } public getWhitespaceCell(attr?: IAttributeData): ICellData { if (attr) { this._whitespaceCell.fg = attr.fg; this._whitespaceCell.bg = attr.bg; this._whitespaceCell.extended = attr.extended; } else { this._whitespaceCell.fg = 0; this._whitespaceCell.bg = 0; this._whitespaceCell.extended = new ExtendedAttrs(); } return this._whitespaceCell; } public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped); } public get hasScrollback(): boolean { return this._hasScrollback && this.lines.maxLength > this._rows; } public get isCursorInViewport(): boolean { const absoluteY = this.ybase + this.y; const relativeY = absoluteY - this.ydisp; return (relativeY >= 0 && relativeY < this._rows); } /** * 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. * @param rows The terminal rows to use in the calculation. */ private _getCorrectBufferLength(rows: number): number { if (!this._hasScrollback) { return rows; } const correctBufferLength = rows + this._optionsService.rawOptions.scrollback; return correctBufferLength > MAX_BUFFER_SIZE ? MAX_BUFFER_SIZE : correctBufferLength; } /** * Fills the buffer's viewport with blank lines. */ public fillViewportRows(fillAttr?: IAttributeData): void { if (this.lines.length === 0) { if (fillAttr === undefined) { fillAttr = DEFAULT_ATTR_DATA; } let i = this._rows; while (i--) { this.lines.push(this.getBlankLine(fillAttr)); } } } /** * Clears the buffer to it's initial state, discarding all previous data. */ public clear(): void { this.ydisp = 0; this.ybase = 0; this.y = 0; this.x = 0; this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); } /** * Resizes the buffer, adjusting its data accordingly. * @param newCols The new number of columns. * @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); // 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; } // The following adjustments should only happen if the buffer has been // initialized/filled. if (this.lines.length > 0) { // Deal with columns increasing (reducing needs to happen after reflow) if (this._cols < newCols) { for (let i = 0; i < this.lines.length; i++) { this.lines.get(i)!.resize(newCols, nullCell); } } // 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.windowsMode) { // 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(newCols, nullCell)); } 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(newCols, nullCell)); } } } } } 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++; } } } } // 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) { // Trim from the top of the buffer and adjust ybase and ydisp. const amountToTrim = this.lines.length - newMaxLength; if (amountToTrim > 0) { this.lines.trimStart(amountToTrim); this.ybase = Math.max(this.ybase - amountToTrim, 0); this.ydisp = Math.max(this.ydisp - amountToTrim, 0); this.savedY = Math.max(this.savedY - amountToTrim, 0); } this.lines.maxLength = newMaxLength; } // 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; } this.scrollBottom = newRows - 1; if (this._isReflowEnabled) { this._reflow(newCols, newRows); // Trim the end of the line off if cols shrunk if (this._cols > newCols) { for (let i = 0; i < this.lines.length; i++) { this.lines.get(i)!.resize(newCols, nullCell); } } } this._cols = newCols; this._rows = newRows; } private get _isReflowEnabled(): boolean { return this._hasScrollback && !this._optionsService.rawOptions.windowsMode; } private _reflow(newCols: number, newRows: number): void { if (this._cols === newCols) { 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); } } private _reflowLarger(newCols: number, newRows: number): void { const toRemove: number[] = reflowLargerGetLinesToRemove(this.lines, this._cols, newCols, this.ybase + this.y, this.getNullCell(DEFAULT_ATTR_DATA)); if (toRemove.length > 0) { const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove); reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout); this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved); } } 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--; } if (this.lines.length < newRows) { // Add an extra row at the bottom of the viewport this.lines.push(new BufferLine(newCols, nullCell)); } } else { if (this.ydisp === this.ybase) { this.ydisp--; } this.ybase--; } } this.savedY = Math.max(this.savedY - countRemoved, 0); } private _reflowSmaller(newCols: number, newRows: number): void { 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 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 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; } wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); destCol -= cellsToCopy; if (destCol === 0) { destLineIndex--; destCol = destLineLengths[destLineIndex]; } srcCol -= cellsToCopy; if (srcCol === 0) { srcLineIndex--; const wrappedLinesIndex = Math.max(srcLineIndex, 0); srcCol = getWrappedLineTrimmedLength(wrappedLines, wrappedLinesIndex, this._cols); } } // Null out the end of the line ends if a wide character wrapped to the following line for (let i = 0; i < wrappedLines.length; i++) { if (destLineLengths[i] < newCols) { wrappedLines[i].setCell(destLineLengths[i], nullCell); } } // Adjust viewport as needed let viewportAdjustments = linesToAdd - trimmedLines; while (viewportAdjustments-- > 0) { 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++; } } } 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--]); } } // 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); } } } // private _reflowSmallerGetLinesNeeded() /** * Translates a string index back to a BufferIndex. * To get the correct buffer position the string must start at `startCol` 0 * (default in translateBufferLineToString). * The method also works on wrapped line strings given rows were not trimmed. * The method operates on the CharData string length, there are no * additional content or boundary checks. Therefore the string and the buffer * should not be altered in between. * TODO: respect trim flag after fixing #1685 * @param lineIndex line index the string was retrieved from * @param stringIndex index within the string * @param startCol column offset the string was retrieved from */ public stringIndexToBufferIndex(lineIndex: number, stringIndex: number, trimRight: boolean = false): BufferIndex { while (stringIndex) { const line = this.lines.get(lineIndex); if (!line) { return [-1, -1]; } const length = (trimRight) ? line.getTrimmedLength() : line.length; for (let i = 0; i < length; ++i) { if (line.get(i)[CHAR_DATA_WIDTH_INDEX]) { // empty cells report a string length of 0, but get replaced // with a whitespace in translateToString, thus replace with 1 stringIndex -= line.get(i)[CHAR_DATA_CHAR_INDEX].length || 1; } if (stringIndex < 0) { return [lineIndex, i]; } } lineIndex++; } return [lineIndex, 0]; } /** * Translates a buffer line to a string, with optional start and end columns. * Wide characters will count as two columns in the resulting string. This * function is useful for getting the actual text underneath the raw selection * position. * @param line The line being translated. * @param trimRight Whether to trim whitespace to the right. * @param startCol The column to start at. * @param endCol The column to end at. */ public translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol: number = 0, endCol?: number): string { const line = this.lines.get(lineIndex); if (!line) { return ''; } return line.translateToString(trimRight, startCol, endCol); } public getWrappedRangeForLine(y: number): { first: number, last: number } { let first = y; let last = y; // Scan upwards for wrapped lines while (first > 0 && this.lines.get(first)!.isWrapped) { first--; } // Scan downwards for wrapped lines while (last + 1 < this.lines.length && this.lines.get(last + 1)!.isWrapped) { last++; } return { first, last }; } /** * Setup the tab stops. * @param i The index to start setting up tab stops from. */ public setupTabStops(i?: number): void { if (i !== null && i !== undefined) { if (!this.tabs[i]) { i = this.prevStop(i); } } else { this.tabs = {}; i = 0; } for (; i < this._cols; i += this._optionsService.rawOptions.tabStopWidth) { this.tabs[i] = true; } } /** * Move the cursor to the previous tab stop from the given position (default is current). * @param x The position to move the cursor to the previous tab stop. */ public prevStop(x?: number): number { if (x === null || x === undefined) { x = this.x; } while (!this.tabs[--x] && x > 0); return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x; } /** * Move the cursor one tab stop forward from the given position (default is current). * @param x The position to move the cursor one tab stop forward. */ public nextStop(x?: number): number { if (x === null || x === undefined) { x = this.x; } while (!this.tabs[++x] && x < this._cols); return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x; } public clearMarkers(y?: number): void { this._isClearing = true; if (y !== undefined) { for (let i = 0; i < this.markers.length; i++) { if (this.markers[i].line === y) { this.markers[i].dispose(); this.markers.splice(i--, 1); } } } else { for (const marker of this.markers) { marker.dispose(); } this.markers = []; } this._isClearing = false; } public addMarker(y: number): Marker { const marker = new Marker(y); this.markers.push(marker); marker.register(this.lines.onTrim(amount => { marker.line -= amount; // The marker should be disposed when the line is trimmed from the buffer if (marker.line < 0) { marker.dispose(); } })); marker.register(this.lines.onInsert(event => { if (marker.line >= event.index) { marker.line += event.amount; } })); marker.register(this.lines.onDelete(event => { // Delete the marker if it's within the range if (marker.line >= event.index && marker.line < event.index + event.amount) { marker.dispose(); } // Shift the marker if it's after the deleted range if (marker.line > event.index) { marker.line -= event.amount; } })); marker.register(marker.onDispose(() => this._removeMarker(marker))); return marker; } private _removeMarker(marker: Marker): void { if (!this._isClearing) { this.markers.splice(this.markers.indexOf(marker), 1); } } public iterator(trimRight: boolean, startIndex?: number, endIndex?: number, startOverscan?: number, endOverscan?: number): IBufferStringIterator { return new BufferStringIterator(this, trimRight, startIndex, endIndex, startOverscan, endOverscan); } } /** * Iterator to get unwrapped content strings from the buffer. * The iterator returns at least the string data between the borders * `startIndex` and `endIndex` (exclusive) and will expand the lines * by `startOverscan` to the top and by `endOverscan` to the bottom, * if no new line was found in between. * It will never read/return string data beyond `startIndex - startOverscan` * or `endIndex + endOverscan`. Therefore the first and last line might be truncated. * It is possible to always get the full string for the first and last line as well * by setting the overscan values to the actual buffer length. This not recommended * since it might return the whole buffer within a single string in a worst case scenario. */ export class BufferStringIterator implements IBufferStringIterator { private _current: number; constructor ( private _buffer: IBuffer, private _trimRight: boolean, private _startIndex: number = 0, private _endIndex: number = _buffer.lines.length, private _startOverscan: number = 0, private _endOverscan: number = 0 ) { if (this._startIndex < 0) { this._startIndex = 0; } if (this._endIndex > this._buffer.lines.length) { this._endIndex = this._buffer.lines.length; } this._current = this._startIndex; } public hasNext(): boolean { return this._current < this._endIndex; } public next(): IBufferStringIteratorResult { const range = this._buffer.getWrappedRangeForLine(this._current); // limit search window to overscan value at both borders if (range.first < this._startIndex - this._startOverscan) { range.first = this._startIndex - this._startOverscan; } if (range.last > this._endIndex + this._endOverscan) { range.last = this._endIndex + this._endOverscan; } // limit to current buffer length range.first = Math.max(range.first, 0); range.last = Math.min(range.last, this._buffer.lines.length); let content = ''; for (let i = range.first; i <= range.last; ++i) { content += this._buffer.translateBufferLineToString(i, this._trimRight); } this._current = range.last + 1; return { range, content }; } }