diff --git a/src/test/escape-sequences-test.js b/src/test/escape-sequences-test.js index 2eb6016556..ace2c3cb7b 100644 --- a/src/test/escape-sequences-test.js +++ b/src/test/escape-sequences-test.js @@ -59,7 +59,7 @@ function terminalToString(term) { for (var line=0; line { + describe('push', () => { + it('should push values onto the array', () => { + const list = new CircularList(5); + list.push('1'); + list.push('2'); + list.push('3'); + list.push('4'); + list.push('5'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + assert.equal(list.get(2), '3'); + assert.equal(list.get(3), '4'); + assert.equal(list.get(4), '5'); + }); + + it('should push old values from the start out of the array when max length is reached', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + list.push('3'); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '3'); + list.push('4'); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + }); + }); + + describe('maxLength', () => { + it('should increase the size of the list', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + list.maxLength = 4; + list.push('3'); + list.push('4'); + assert.equal(list.get(0), '1'); + assert.equal(list.get(1), '2'); + assert.equal(list.get(2), '3'); + assert.equal(list.get(3), '4'); + list.push('wrapped'); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '3'); + assert.equal(list.get(2), '4'); + assert.equal(list.get(3), 'wrapped'); + }); + + it('should return the maximum length of the list', () => { + const list = new CircularList(2); + assert.equal(list.maxLength, 2); + list.push('1'); + list.push('2'); + assert.equal(list.maxLength, 2); + list.push('3'); + assert.equal(list.maxLength, 2); + list.maxLength = 4; + assert.equal(list.maxLength, 4); + }); + }); + + describe('length', () => { + it('should return the current length of the list, capped at the maximum length', () => { + const list = new CircularList(2); + assert.equal(list.length, 0); + list.push('1'); + assert.equal(list.length, 1); + list.push('2'); + assert.equal(list.length, 2); + list.push('3'); + assert.equal(list.length, 2); + }); + }); + + describe('splice', () => { + it('should delete items', () => { + const list = new CircularList(2); + list.push('1'); + list.push('2'); + list.splice(0, 1); + assert.equal(list.length, 1); + assert.equal(list.get(0), '2'); + list.push('3'); + list.splice(1, 1); + assert.equal(list.length, 1); + assert.equal(list.get(0), '2'); + }); + + it('should insert items', () => { + const list = new CircularList(2); + list.push('1'); + list.splice(0, 0, '2'); + assert.equal(list.length, 2); + assert.equal(list.get(0), '2'); + assert.equal(list.get(1), '1'); + list.splice(1, 0, '3'); + assert.equal(list.length, 2); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '1'); + }); + + it('should delete items then insert items', () => { + const list = new CircularList(3); + list.push('1'); + list.push('2'); + list.splice(0, 1, '3', '4'); + assert.equal(list.length, 3); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + assert.equal(list.get(2), '2'); + }); + + it('should wrap the array correctly when more items are inserted than deleted', () => { + const list = new CircularList(3); + list.push('1'); + list.push('2'); + list.splice(1, 0, '3', '4'); + assert.equal(list.length, 3); + assert.equal(list.get(0), '3'); + assert.equal(list.get(1), '4'); + assert.equal(list.get(2), '2'); + }); + }); + + describe('trimStart', () => { + it('should remove items from the beginning of the list', () => { + const list = new CircularList(5); + list.push('1'); + list.push('2'); + list.push('3'); + list.push('4'); + list.push('5'); + list.trimStart(1); + assert.equal(list.length, 4); + assert.deepEqual(list.get(0), '2'); + assert.deepEqual(list.get(1), '3'); + assert.deepEqual(list.get(2), '4'); + assert.deepEqual(list.get(3), '5'); + list.trimStart(2); + assert.equal(list.length, 2); + assert.deepEqual(list.get(0), '4'); + assert.deepEqual(list.get(1), '5'); + }); + + it('should remove all items if the requested trim amount is larger than the list\'s length', () => { + const list = new CircularList(5); + list.push('1'); + list.trimStart(2); + assert.equal(list.length, 0); + }); + }); + + describe('shiftElements', () => { + it('should not mutate the list when count is 0', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 0, 1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + }); + + it('should throw for invalid args', () => { + const list = new CircularList(5); + list.push(1); + assert.throws(() => list.shiftElements(-1, 1, 1), 'start argument out of range'); + assert.throws(() => list.shiftElements(1, 1, 1), 'start argument out of range'); + assert.throws(() => list.shiftElements(0, 1, -1), 'Cannot shift elements in list beyond index 0'); + }); + + it('should shift an element forward', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 1, 1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 1); + }); + + it('should shift elements forward', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.shiftElements(0, 2, 2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + assert.equal(list.get(2), 1); + assert.equal(list.get(3), 2); + }); + + it('should shift elements forward, expanding the list if needed', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(0, 2, 2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 1); + assert.equal(list.get(1), 2); + assert.equal(list.get(2), 1); + assert.equal(list.get(3), 2); + }); + + it('should shift elements forward, wrapping the list if needed', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.push(5); + list.shiftElements(2, 2, 3); + assert.equal(list.length, 5); + assert.equal(list.get(0), 3); + assert.equal(list.get(1), 4); + assert.equal(list.get(2), 5); + assert.equal(list.get(3), 3); + assert.equal(list.get(4), 4); + }); + + it('should shift an element backwards', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.shiftElements(1, 1, -1); + assert.equal(list.length, 2); + assert.equal(list.get(0), 2); + assert.equal(list.get(1), 2); + }); + + it('should shift elements backwards', () => { + const list = new CircularList(5); + list.push(1); + list.push(2); + list.push(3); + list.push(4); + list.shiftElements(2, 2, -2); + assert.equal(list.length, 4); + assert.equal(list.get(0), 3); + assert.equal(list.get(1), 4); + assert.equal(list.get(2), 3); + assert.equal(list.get(3), 4); + }); + }); +}); diff --git a/src/utils/CircularList.ts b/src/utils/CircularList.ts new file mode 100644 index 0000000000..b72c667fe1 --- /dev/null +++ b/src/utils/CircularList.ts @@ -0,0 +1,183 @@ +/** + * Represents a circular list; a list with a maximum size that wraps around when push is called, + * overriding values at the start of the list. + * @module xterm/utils/CircularList + * @license MIT + */ +export class CircularList { + private _array: T[]; + private _startIndex: number; + private _length: number; + + constructor(maxLength: number) { + this._array = new Array(maxLength); + this._startIndex = 0; + this._length = 0; + } + + public get maxLength(): number { + return this._array.length; + } + + public set maxLength(newMaxLength: number) { + // Reconstruct array, starting at index 0. Only transfer values from the + // indexes 0 to length. + let newArray = new Array(newMaxLength); + for (let i = 0; i < Math.min(newMaxLength, this.length); i++) { + newArray[i] = this._array[this._getCyclicIndex(i)]; + } + this._array = newArray; + this._startIndex = 0; + } + + public get length(): number { + return this._length; + } + + public set length(newLength: number) { + if (newLength > this._length) { + for (let i = this._length; i < newLength; i++) { + this._array[i] = undefined; + } + } + this._length = newLength; + } + + public get forEach(): (callbackfn: (value: T, index: number, array: T[]) => void) => void { + return this._array.forEach; + } + + /** + * Gets the value at an index. + * + * Note that for performance reasons there is no bounds checking here, the index reference is + * circular so this should always return a value and never throw. + * @param index The index of the value to get. + * @return The value corresponding to the index. + */ + public get(index: number): T { + return this._array[this._getCyclicIndex(index)]; + } + + /** + * Sets the value at an index. + * + * Note that for performance reasons there is no bounds checking here, the index reference is + * circular so this should always return a value and never throw. + * @param index The index to set. + * @param value The value to set. + */ + public set(index: number, value: T): void { + this._array[this._getCyclicIndex(index)] = value; + } + + /** + * Pushes a new value onto the list, wrapping around to the start of the array, overriding index 0 + * if the maximum length is reached. + * @param value The value to push onto the list. + */ + public push(value: T): void { + this._array[this._getCyclicIndex(this._length)] = value; + if (this._length === this.maxLength) { + this._startIndex++; + if (this._startIndex === this.maxLength) { + this._startIndex = 0; + } + } else { + this._length++; + } + } + + /** + * Removes and returns the last value on the list. + * @return The popped value. + */ + public pop(): T { + return this._array[this._getCyclicIndex(this._length-- - 1)]; + } + + /** + * Deletes and/or inserts items at a particular index (in that order). Unlike + * Array.prototype.splice, this operation does not return the deleted items as a new array in + * order to save creating a new array. Note that this operation may shift all values in the list + * in the worst case. + * @param start The index to delete and/or insert. + * @param deleteCount The number of elements to delete. + * @param items The items to insert. + */ + public splice(start: number, deleteCount: number, ...items: T[]): void { + if (deleteCount) { + for (let i = start; i < this._length - deleteCount; i++) { + this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; + } + this._length -= deleteCount; + } + if (items && items.length) { + for (let i = this._length - 1; i >= start; i--) { + this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)]; + } + for (let i = 0; i < items.length; i++) { + this._array[this._getCyclicIndex(start + i)] = items[i]; + } + + if (this._length + items.length > this.maxLength) { + this._startIndex += (this._length + items.length) - this.maxLength; + this._length = this.maxLength; + } else { + this._length += items.length; + } + } + } + + /** + * Trims a number of items from the start of the list. + * @param count The number of items to remove. + */ + public trimStart(count: number): void { + if (count > this._length) { + count = this._length; + } + this._startIndex += count; + this._length -= count; + } + + public shiftElements(start: number, count: number, offset: number): void { + if (count <= 0) { + return; + } + if (start < 0 || start >= this._length) { + throw new Error('start argument out of range'); + } + if (start + offset < 0) { + throw new Error('Cannot shift elements in list beyond index 0'); + } + + if (offset > 0) { + for (let i = count - 1; i >= 0; i--) { + this.set(start + i + offset, this.get(start + i)); + } + const expandListBy = (start + count + offset) - this._length; + if (expandListBy > 0) { + this._length += expandListBy; + while (this._length > this.maxLength) { + this._length--; + this._startIndex++; + } + } + } else { + for (let i = 0; i < count; i++) { + this.set(start + i + offset, this.get(start + i)); + } + } + } + + /** + * Gets the cyclic index for the specified regular index. The cyclic index can then be used on the + * backing array to get the element associated with the regular index. + * @param index The regular index. + * @returns The cyclic index. + */ + private _getCyclicIndex(index: number): number { + return (this._startIndex + index) % this.maxLength; + } +} diff --git a/src/xterm.js b/src/xterm.js index a085fc7019..a475b75f87 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -14,6 +14,7 @@ import { CompositionHelper } from './CompositionHelper.js'; import { EventEmitter } from './EventEmitter.js'; import { Viewport } from './Viewport.js'; import { rightClickHandler, pasteHandler, copyHandler } from './handlers/Clipboard.js'; +import { CircularList } from './utils/CircularList.js'; import * as Browser from './utils/Browser'; import * as Keyboard from './utils/Keyboard'; @@ -208,7 +209,7 @@ function Terminal(options) { * An array of all lines in the entire buffer, including the prompt. The lines are array of * characters which are 2-length arrays where [0] is an attribute and [1] is the character. */ - this.lines = []; + this.lines = new CircularList(this.scrollback); var i = this.rows; while (i--) { this.lines.push(this.blankLine()); @@ -1078,7 +1079,7 @@ Terminal.prototype.refresh = function(start, end, queue) { for (; y <= end; y++) { row = y + this.ydisp; - line = this.lines[row]; + line = this.lines.get(row); out = ''; if (this.y === y - (this.ybase - this.ydisp) @@ -1228,16 +1229,14 @@ Terminal.prototype.showCursor = function() { }; /** - * Scroll the terminal + * Scroll the terminal down 1 row, creating a blank line. */ Terminal.prototype.scroll = function() { var row; - if (++this.ybase === this.scrollback) { - this.ybase = this.ybase / 2 | 0; - this.lines = this.lines.slice(-(this.ybase + this.rows) + 1); - } + this.ybase++; + // TODO: Why is this done twice? if (!this.userScrolling) { this.ydisp = this.ybase; } @@ -1249,10 +1248,12 @@ Terminal.prototype.scroll = function() { row -= this.rows - 1 - this.scrollBottom; if (row === this.lines.length) { - // potential optimization: - // pushing is faster than splicing - // when they amount to the same - // behavior. + // Compensate ybase and ydisp if lines has hit the maximum buffer size + if (this.lines.length === this.lines.maxLength) { + this.ybase--; + this.ydisp--; + } + // Optimization: pushing is faster than splicing when they amount to the same behavior this.lines.push(this.blankLine()); } else { // add our new line @@ -1370,7 +1371,6 @@ Terminal.prototype.write = function(data) { // surrogate low - already handled above if (0xDC00 <= code && code <= 0xDFFF) continue; - switch (this.state) { case normal: switch (ch) { @@ -1440,17 +1440,16 @@ Terminal.prototype.write = function(data) { // insert combining char in last cell // FIXME: needs handling after cursor jumps if (!ch_width && this.x) { - // dont overflow left - if (this.lines[row][this.x-1]) { - if (!this.lines[row][this.x-1][2]) { + if (this.lines.get(row)[this.x-1]) { + if (!this.lines.get(row)[this.x-1][2]) { // found empty cell after fullwidth, need to go 2 cells back - if (this.lines[row][this.x-2]) - this.lines[row][this.x-2][1] += ch; + if (this.lines.get(row)[this.x-2]) + this.lines.get(row)[this.x-2][1] += ch; } else { - this.lines[row][this.x-1][1] += ch; + this.lines.get(row)[this.x-1][1] += ch; } this.updateRange(this.y); } @@ -1482,24 +1481,24 @@ Terminal.prototype.write = function(data) { for (var moves=0; moves x) i = this.lines.length; while (i--) { - while (this.lines[i].length > x) { - this.lines[i].pop(); + while (this.lines.get(i).length > x) { + this.lines.get(i).pop(); } } } @@ -3029,7 +3028,7 @@ Terminal.prototype.nextStop = function(x) { * @param {number} y The line in which to operate. */ Terminal.prototype.eraseRight = function(x, y) { - var line = this.lines[this.ybase + y] + var line = this.lines.get(this.ybase + y) , ch = [this.eraseAttr(), ' ', 1]; // xterm @@ -3048,7 +3047,7 @@ Terminal.prototype.eraseRight = function(x, y) { * @param {number} y The line in which to operate. */ Terminal.prototype.eraseLeft = function(x, y) { - var line = this.lines[this.ybase + y] + var line = this.lines.get(this.ybase + y) , ch = [this.eraseAttr(), ' ', 1]; // xterm x++; @@ -3065,7 +3064,8 @@ Terminal.prototype.clear = function() { // Don't clear if it's already clear return; } - this.lines = [this.lines[this.ybase + this.y]]; + this.lines.set(0, this.lines.get(this.ybase + this.y)); + this.lines.length = 1; this.ydisp = 0; this.ybase = 0; this.y = 0; @@ -3086,7 +3086,7 @@ Terminal.prototype.eraseLine = function(y) { /** - * Return the data array of a blank line/ + * Return the data array of a blank line * @param {number} cur First bunch of data for each "blank" character. */ Terminal.prototype.blankLine = function(cur) { @@ -3174,21 +3174,21 @@ Terminal.prototype.index = function() { /** * ESC M Reverse Index (RI is 0x8d). + * + * Move the cursor up one row, inserting a new blank line if necessary. */ Terminal.prototype.reverseIndex = function() { var j; - this.y--; - if (this.y < this.scrollTop) { - this.y++; + if (this.y === this.scrollTop) { // possibly move the code below to term.reverseScroll(); // test: echo -ne '\e[1;1H\e[44m\eM\e[0m' // blankLine(true) is xterm/linux behavior - this.lines.splice(this.y + this.ybase, 0, this.blankLine(true)); - j = this.rows - 1 - this.scrollBottom; - this.lines.splice(this.rows - 1 + this.ybase - j + 1, 1); - // this.maxRange(); + this.lines.shiftElements(this.y + this.ybase, this.rows - 1, 1); + this.lines.set(this.y + this.ybase, this.blankLine(true)); this.updateRange(this.scrollTop); this.updateRange(this.scrollBottom); + } else { + this.y--; } this.state = normal; }; @@ -3644,8 +3644,8 @@ Terminal.prototype.insertChars = function(params) { ch = [this.eraseAttr(), ' ', 1]; // xterm while (param-- && j < this.cols) { - this.lines[row].splice(j++, 0, ch); - this.lines[row].pop(); + this.lines.get(row).splice(j++, 0, ch); + this.lines.get(row).pop(); } }; @@ -3705,6 +3705,14 @@ Terminal.prototype.insertLines = function(params) { j = this.rows - 1 + this.ybase - j + 1; while (param--) { + if (this.lines.length === this.lines.maxLength) { + // Trim the start of lines to make room for the new line + this.lines.trimStart(1); + this.ybase--; + this.ydisp--; + row--; + j--; + } // test: echo -e '\e[44m\e[1L\e[0m' // blankLine(true) - xterm/linux behavior this.lines.splice(row, 0, this.blankLine(true)); @@ -3732,6 +3740,12 @@ Terminal.prototype.deleteLines = function(params) { j = this.rows - 1 + this.ybase - j; while (param--) { + if (this.lines.length === this.lines.maxLength) { + // Trim the start of lines to make room for the new line + this.lines.trimStart(1); + this.ybase -= 1; + this.ydisp -= 1; + } // test: echo -e '\e[44m\e[1M\e[0m' // blankLine(true) - xterm/linux behavior this.lines.splice(j + 1, 0, this.blankLine(true)); @@ -3758,8 +3772,8 @@ Terminal.prototype.deleteChars = function(params) { ch = [this.eraseAttr(), ' ', 1]; // xterm while (param--) { - this.lines[row].splice(this.x, 1); - this.lines[row].push(ch); + this.lines.get(row).splice(this.x, 1); + this.lines.get(row).push(ch); } }; @@ -3778,7 +3792,7 @@ Terminal.prototype.eraseChars = function(params) { ch = [this.eraseAttr(), ' ', 1]; // xterm while (param-- && j < this.cols) { - this.lines[row][j++] = ch; + this.lines.get(row)[j++] = ch; } }; @@ -4442,7 +4456,7 @@ Terminal.prototype.cursorBackwardTab = function(params) { */ Terminal.prototype.repeatPrecedingCharacter = function(params) { var param = params[0] || 1 - , line = this.lines[this.ybase + this.y] + , line = this.lines.get(this.ybase + this.y) , ch = line[this.x - 1] || [this.defAttr, ' ', 1]; while (param--) line[this.x++] = ch; @@ -4677,7 +4691,7 @@ Terminal.prototype.setAttrInRectangle = function(params) { , i; for (; t < b + 1; t++) { - line = this.lines[this.ybase + t]; + line = this.lines.get(this.ybase + t); for (i = l; i < r; i++) { line[i] = [attr, line[i][1]]; } @@ -4707,7 +4721,7 @@ Terminal.prototype.fillRectangle = function(params) { , i; for (; t < b + 1; t++) { - line = this.lines[this.ybase + t]; + line = this.lines.get(this.ybase + t); for (i = l; i < r; i++) { line[i] = [line[i][0], String.fromCharCode(ch)]; } @@ -4759,7 +4773,7 @@ Terminal.prototype.eraseRectangle = function(params) { ch = [this.eraseAttr(), ' ', 1]; // xterm? for (; t < b + 1; t++) { - line = this.lines[this.ybase + t]; + line = this.lines.get(this.ybase + t); for (i = l; i < r; i++) { line[i] = ch; } @@ -4784,8 +4798,8 @@ Terminal.prototype.insertColumns = function() { while (param--) { for (i = this.ybase; i < l; i++) { - this.lines[i].splice(this.x + 1, 0, ch); - this.lines[i].pop(); + this.lines.get(i).splice(this.x + 1, 0, ch); + this.lines.get(i).pop(); } } @@ -4806,8 +4820,8 @@ Terminal.prototype.deleteColumns = function() { while (param--) { for (i = this.ybase; i < l; i++) { - this.lines[i].splice(this.x, 1); - this.lines[i].push(ch); + this.lines.get(i).splice(this.x, 1); + this.lines.get(i).push(ch); } }