diff --git a/src/Viewport.ts b/src/Viewport.ts index 013ae65a..aaafbc5f 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -46,13 +46,13 @@ export class Viewport { */ private refresh(): void { if (this.charMeasure.height > 0) { - var rowHeightChanged = this.charMeasure.height !== this.currentRowHeight; + const rowHeightChanged = this.charMeasure.height !== this.currentRowHeight; if (rowHeightChanged) { this.currentRowHeight = this.charMeasure.height; this.viewportElement.style.lineHeight = this.charMeasure.height + 'px'; this.terminal.rowContainer.style.lineHeight = this.charMeasure.height + 'px'; } - var viewportHeightChanged = this.lastRecordedViewportHeight !== this.terminal.rows; + const viewportHeightChanged = this.lastRecordedViewportHeight !== this.terminal.rows; if (rowHeightChanged || viewportHeightChanged) { this.lastRecordedViewportHeight = this.terminal.rows; this.viewportElement.style.height = this.charMeasure.height * this.terminal.rows + 'px'; @@ -80,7 +80,7 @@ export class Viewport { } // Sync scrollTop - var scrollTop = this.terminal.ydisp * this.currentRowHeight; + const scrollTop = this.terminal.ydisp * this.currentRowHeight; if (this.viewportElement.scrollTop !== scrollTop) { this.viewportElement.scrollTop = scrollTop; } @@ -92,8 +92,8 @@ export class Viewport { * @param ev The scroll event. */ private onScroll(ev: Event) { - var newRow = Math.round(this.viewportElement.scrollTop / this.currentRowHeight); - var diff = newRow - this.terminal.ydisp; + const newRow = Math.round(this.viewportElement.scrollTop / this.currentRowHeight); + const diff = newRow - this.terminal.ydisp; this.terminal.scrollDisp(diff, true); } @@ -109,7 +109,7 @@ export class Viewport { return; } // Fallback to WheelEvent.DOM_DELTA_PIXEL - var multiplier = 1; + let multiplier = 1; if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) { multiplier = this.currentRowHeight; } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { diff --git a/src/test/escape-sequences-test.js b/src/test/escape-sequences-test.js index ace2c3cb..e9183ccf 100644 --- a/src/test/escape-sequences-test.js +++ b/src/test/escape-sequences-test.js @@ -92,7 +92,11 @@ describe('xterm output comparison', function() { var from_pty = pty_write_read(in_file); // uncomment this to get log from terminal //console.log = function(){}; - xterm.write(from_pty); + + // Perform a synchronous .write(data) + xterm.writeBuffer.push(from_pty); + xterm.innerWrite(); + var from_emulator = terminalToString(xterm); console.log = CONSOLE_LOG; var expected = fs.readFileSync(filename.split('.')[0] + '.text', 'utf8'); diff --git a/src/test/test.js b/src/test/test.js index a44d2066..7c716b2a 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -14,6 +14,15 @@ describe('xterm.js', function() { xterm.compositionHelper = { keydown: function(){ return true; } }; + // Force synchronous refreshes + xterm.queueRefresh = function(start, end) { + xterm.refresh(start, end); + }; + // Force synchronous writes + xterm.write = function(data) { + xterm.writeBuffer.push(data); + xterm.innerWrite(); + }; }); describe('getOption', function() { diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index 60684a2c..6ca5046a 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -28,24 +28,29 @@ export class CharMeasure extends EventEmitter { } public measure(): void { - const oldWidth = this._width; - const oldHeight = this._height; - if (!this._measureElement) { this._measureElement = document.createElement('span'); this._measureElement.style.position = 'absolute'; this._measureElement.style.top = '0'; this._measureElement.style.left = '-9999em'; this._measureElement.textContent = 'W'; + this._parentElement.appendChild(this._measureElement); + // Perform _doMeasure async if the element was just attached as sometimes + // getBoundingClientRect does not return accurate values without this. + setTimeout(() => this._doMeasure(), 0); + } else { + this._doMeasure(); } + } - this._parentElement.appendChild(this._measureElement); + private _doMeasure(): void { + const oldWidth = this._width; + const oldHeight = this._height; const geometry = this._measureElement.getBoundingClientRect(); - this._width = geometry.width; - this._height = geometry.height; - this._parentElement.removeChild(this._measureElement); - if (this._width !== oldWidth || this._height !== oldHeight) { + if (this._width !== geometry.width || this._height !== geometry.height) { + this._width = geometry.width; + this._height = geometry.height; this.emit('charsizechanged'); } } diff --git a/src/xterm.js b/src/xterm.js index 035ce101..62baf517 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -42,6 +42,26 @@ var document = (typeof window != 'undefined') ? window.document : null; */ var normal = 0, escaped = 1, csi = 2, osc = 3, charset = 4, dcs = 5, ignore = 6; +/** + * The amount of write requests to queue before sending an XOFF signal to the + * pty process. This number must be small in order for ^C and similar sequences + * to be responsive. + */ +var WRITE_BUFFER_PAUSE_THRESHOLD = 5; + +/** + * The number of writes to perform in a single batch before allowing the + * renderer to catch up with a 0ms setTimeout. + */ +var WRITE_BATCH_SIZE = 300; + +/** + * The maximum number of refresh frames to skip when the write buffer is non- + * empty. Note that these frames may be intermingled with frames that are + * skipped via requestAnimationFrame's mechanism. + */ +var MAX_REFRESH_FRAME_SKIP = 5; + /** * Terminal */ @@ -203,6 +223,22 @@ function Terminal(options) { this.inputHandler = new InputHandler(this); this.parser = new Parser(this.inputHandler, this); + // user input states + this.writeBuffer = []; + this.writeInProgress = false; + this.refreshFramesSkipped = 0; + + /** + * Whether _xterm.js_ sent XOFF in order to catch up with the pty process. + * This is a distinct state from writeStopped so that if the user requested + * XOFF via ^S that it will not automatically resume when the writeBuffer goes + * below threshold. + */ + this.xoffSentToCatchUp = false; + + /** Whether writing has been stopped as a result of XOFF */ + this.writeStopped = false; + // leftover surrogate high from previous write invocation this.surrogate_high = ''; @@ -1028,27 +1064,36 @@ Terminal.prototype.queueRefresh = function(start, end) { Terminal.prototype.refreshLoop = function() { // Don't refresh if there were no row changes if (this.refreshRowsQueue.length > 0) { - var start; - var end; - if (this.refreshRowsQueue.length > 4) { - // Just do a full refresh when 5+ refreshes are queued - start = 0; - end = this.rows - 1; - } else { - // Get start and end rows that need refreshing - start = this.refreshRowsQueue[0].start; - end = this.refreshRowsQueue[0].end; - for (var i = 1; i < this.refreshRowsQueue.length; i++) { - if (this.refreshRowsQueue[i].start < start) { - start = this.refreshRowsQueue[i].start; - } - if (this.refreshRowsQueue[i].end > end) { - end = this.refreshRowsQueue[i].end; + // Skip MAX_REFRESH_FRAME_SKIP frames if the writeBuffer is non-empty as it + // will need to be immediately refreshed anyway. This saves a lot of + // rendering time as the viewport DOM does not need to be refreshed, no + // scroll events, no layouts, etc. + var skipFrame = this.writeBuffer.length > 0 && this.refreshFramesSkipped++ <= MAX_REFRESH_FRAME_SKIP; + + if (!skipFrame) { + this.refreshFramesSkipped = 0; + var start; + var end; + if (this.refreshRowsQueue.length > 4) { + // Just do a full refresh when 5+ refreshes are queued + start = 0; + end = this.rows - 1; + } else { + // Get start and end rows that need refreshing + start = this.refreshRowsQueue[0].start; + end = this.refreshRowsQueue[0].end; + for (var i = 1; i < this.refreshRowsQueue.length; i++) { + if (this.refreshRowsQueue[i].start < start) { + start = this.refreshRowsQueue[i].start; + } + if (this.refreshRowsQueue[i].end > end) { + end = this.refreshRowsQueue[i].end; + } } } + this.refreshRowsQueue = []; + this.refresh(start, end); } - this.refreshRowsQueue = []; - this.refresh(start, end); } window.requestAnimationFrame(this.refreshLoop.bind(this)); } @@ -1368,13 +1413,59 @@ Terminal.prototype.scrollToBottom = function() { * @param {string} text The text to write to the terminal. */ Terminal.prototype.write = function(data) { - this.refreshStart = this.y; - this.refreshEnd = this.y; + this.writeBuffer.push(data); - this.parser.parse(data); + // Send XOFF to pause the pty process if the write buffer becomes too large so + // xterm.js can catch up before more data is sent. This is necessary in order + // to keep signals such as ^C responsive. + if (!this.xoffSentToCatchUp && this.writeBuffer.length >= WRITE_BUFFER_PAUSE_THRESHOLD) { + // XOFF - stop pty pipe + // XON will be triggered by emulator before processing data chunk + this.send(C0.DC3); + this.xoffSentToCatchUp = true; + } - this.updateRange(this.y); - this.queueRefresh(this.refreshStart, this.refreshEnd); + if (!this.writeInProgress && this.writeBuffer.length > 0) { + // Kick off a write which will write all data in sequence recursively + this.writeInProgress = true; + // Kick off an async innerWrite so more writes can come in while processing data + var self = this; + setTimeout(function () { + self.innerWrite(); + }); + } +} + +Terminal.prototype.innerWrite = function() { + var writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE); + while (writeBatch.length > 0) { + var data = writeBatch.shift(); + var l = data.length, i = 0, j, cs, ch, code, low, ch_width, row; + + // If XOFF was sent in order to catch up with the pty process, resume it if + // the writeBuffer is empty to allow more data to come in. + if (this.xoffSentToCatchUp && writeBatch.length === 0 && this.writeBuffer.length === 0) { + this.send(C0.DC1); + this.xoffSentToCatchUp = false; + } + + this.refreshStart = this.y; + this.refreshEnd = this.y; + + this.parser.parse(data); + + this.updateRange(this.y); + this.queueRefresh(this.refreshStart, this.refreshEnd); + } + if (this.writeBuffer.length > 0) { + // Allow renderer to catch up before processing the next batch + var self = this; + setTimeout(function () { + self.innerWrite(); + }, 0); + } else { + this.writeInProgress = false; + } }; /** @@ -1418,6 +1509,12 @@ Terminal.prototype.keyDown = function(ev) { var self = this; var result = this.evaluateKeyEscapeSequence(ev); + if (result.key === C0.DC3) { // XOFF + this.writeStopped = true; + } else if (result.key === C0.DC1) { // XON + this.writeStopped = false; + } + if (result.scrollDisp) { this.scrollDisp(result.scrollDisp); return this.cancel(ev, true); @@ -1723,6 +1820,7 @@ Terminal.prototype.evaluateKeyEscapeSequence = function(ev) { } break; } + return result; };