diff --git a/README.md b/README.md index 26f98382..48a46fd9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ var xterm = new Terminal(); // Instantiate the terminal xterm.fit(); // Use the `fit` method, provided by the `fit` addon ``` +#### Third party addons + +There are also the following third party addons available: + +- [xterm-webfont](https://www.npmjs.com/package/xterm-webfont) + ## Browser Support Since xterm.js is typically implemented as a developer tool, only modern browsers are supported officially. Here is a list of the versions we aim to support: @@ -128,6 +134,7 @@ computational environment for Jupyter, supporting interactive data science and s - [**Microsoft SQL Operations Studio**](https://github.com/Microsoft/sqlopsstudio): A data management tool that enables working with SQL Server, Azure SQL DB and SQL DW from Windows, macOS and Linux - [**FreeMAN**](https://github.com/matthew-matvei/freeman): A free, cross-platform file manager for power users - [**Fluent Terminal**](https://github.com/felixse/FluentTerminal): A terminal emulator based on UWP and web technologies. +- [**Hyper**](https://hyper.is): A terminal built on web technologies Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it in our list. diff --git a/package.json b/package.json index 0b1f8cc7..49ccc387 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "scripts": { "start": "node demo/app", "start-zmodem": "node demo/zmodem/app", - "lint": "tslint src/*.ts src/**/*.ts", + "lint": "tslint src/*.ts src/**/*.ts src/addons/**/*.ts", "test": "npm-run-all mocha lint", "mocha": "gulp test", "build:docs": "jsdoc -c jsdoc.json", diff --git a/src/Buffer.ts b/src/Buffer.ts index c1cf3cc7..1ea303b2 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -22,8 +22,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 * - scroll position */ export class Buffer implements IBuffer { - private _lines: CircularList; - + public lines: CircularList; public ydisp: number; public ybase: number; public y: number; @@ -48,10 +47,6 @@ export class Buffer implements IBuffer { this.clear(); } - public get lines(): CircularList { - return this._lines; - } - public get hasScrollback(): boolean { return this._hasScrollback && this.lines.maxLength > this._terminal.rows; } @@ -81,7 +76,7 @@ export class Buffer implements IBuffer { * Fills the buffer's viewport with blank lines. */ public fillViewportRows(): void { - if (this._lines.length === 0) { + if (this.lines.length === 0) { let i = this._terminal.rows; while (i--) { this.lines.push(this._terminal.blankLine()); @@ -97,7 +92,7 @@ export class Buffer implements IBuffer { this.ybase = 0; this.y = 0; this.x = 0; - this._lines = new CircularList(this._getCorrectBufferLength(this._terminal.rows)); + this.lines = new CircularList(this._getCorrectBufferLength(this._terminal.rows)); this.scrollTop = 0; this.scrollBottom = this._terminal.rows - 1; this.setupTabStops(); @@ -112,19 +107,19 @@ export class Buffer implements IBuffer { // 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 (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) { + if (this.lines.length > 0) { // Deal with columns increasing (we don't do anything when columns reduce) if (this._terminal.cols < newCols) { const ch: CharData = [this._terminal.defAttr, ' ', 1, 32]; // does xterm use the default attr? - for (let i = 0; i < this._lines.length; i++) { - while (this._lines.get(i).length < newCols) { - this._lines.get(i).push(ch); + for (let i = 0; i < this.lines.length; i++) { + while (this.lines.get(i).length < newCols) { + this.lines.get(i).push(ch); } } } @@ -133,8 +128,8 @@ export class Buffer implements IBuffer { let addToY = 0; if (this._terminal.rows < newRows) { for (let y = this._terminal.rows; y < newRows; y++) { - if (this._lines.length < newRows + this.ybase) { - if (this.ybase > 0 && this._lines.length <= this.ybase + this.y + addToY + 1) { + if (this.lines.length < newRows + this.ybase) { + 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--; @@ -146,16 +141,16 @@ export class Buffer implements IBuffer { } 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(this._terminal.blankLine(undefined, undefined, newCols)); + this.lines.push(this._terminal.blankLine(undefined, undefined, newCols)); } } } } else { // (this._terminal.rows >= newRows) for (let y = this._terminal.rows; y > newRows; y--) { - if (this._lines.length > newRows + this.ybase) { - if (this._lines.length > this.ybase + this.y + 1) { + 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(); + this.lines.pop(); } else { // The line is the cursor, scroll down this.ybase++; @@ -167,15 +162,15 @@ export class Buffer implements IBuffer { // 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) { + if (newMaxLength < this.lines.maxLength) { // Trim from the top of the buffer and adjust ybase and ydisp. - const amountToTrim = this._lines.length - newMaxLength; + const amountToTrim = this.lines.length - newMaxLength; if (amountToTrim > 0) { - this._lines.trimStart(amountToTrim); + this.lines.trimStart(amountToTrim); this.ybase = Math.max(this.ybase - amountToTrim, 0); this.ydisp = Math.max(this.ydisp - amountToTrim, 0); } - this._lines.maxLength = newMaxLength; + this.lines.maxLength = newMaxLength; } // Make sure that the cursor stays on screen @@ -310,7 +305,7 @@ export class Buffer implements IBuffer { public addMarker(y: number): Marker { const marker = new Marker(y); this.markers.push(marker); - marker.disposables.push(this._lines.addDisposableListener('trim', amount => { + marker.disposables.push(this.lines.addDisposableListener('trim', amount => { marker.line -= amount; // The marker should be disposed when the line is trimmed from the buffer if (marker.line < 0) { diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index cece1b9d..0698386e 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -6,7 +6,7 @@ import { XtermListener } from './Types'; import { IEventEmitter, IDisposable } from 'xterm'; -export class EventEmitter implements IEventEmitter { +export class EventEmitter implements IEventEmitter, IDisposable { private _events: {[type: string]: XtermListener[]}; constructor() { @@ -75,7 +75,7 @@ export class EventEmitter implements IEventEmitter { return this._events[type] || []; } - protected destroy(): void { + public dispose(): void { this._events = {}; } } diff --git a/src/InputHandler.ts b/src/InputHandler.ts index acf7af1f..4df3e695 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -23,6 +23,10 @@ export class InputHandler implements IInputHandler { public addChar(char: string, code: number): void { if (char >= ' ') { + + // make buffer local for faster access + const buffer = this._terminal.buffer; + // calculate print space // expensive call, therefore we save width in line buffer const chWidth = wcwidth(code); @@ -35,42 +39,42 @@ export class InputHandler implements IInputHandler { this._terminal.emit('a11y.char', char); } - let row = this._terminal.buffer.y + this._terminal.buffer.ybase; + let row = buffer.y + buffer.ybase; // insert combining char in last cell // FIXME: needs handling after cursor jumps - if (!chWidth && this._terminal.buffer.x) { + if (!chWidth && buffer.x) { // dont overflow left - if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1]) { - if (!this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_WIDTH_INDEX]) { + if (buffer.lines.get(row)[buffer.x - 1]) { + if (!buffer.lines.get(row)[buffer.x - 1][CHAR_DATA_WIDTH_INDEX]) { // found empty cell after fullwidth, need to go 2 cells back - if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2]) { - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][CHAR_DATA_CHAR_INDEX] += char; - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][3] = char.charCodeAt(0); + if (buffer.lines.get(row)[buffer.x - 2]) { + buffer.lines.get(row)[buffer.x - 2][CHAR_DATA_CHAR_INDEX] += char; + buffer.lines.get(row)[buffer.x - 2][3] = char.charCodeAt(0); } } else { - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_CHAR_INDEX] += char; - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][3] = char.charCodeAt(0); + buffer.lines.get(row)[buffer.x - 1][CHAR_DATA_CHAR_INDEX] += char; + buffer.lines.get(row)[buffer.x - 1][3] = char.charCodeAt(0); } - this._terminal.updateRange(this._terminal.buffer.y); + this._terminal.updateRange(buffer.y); } return; } // goto next line if ch would overflow // TODO: needs a global min terminal width of 2 - if (this._terminal.buffer.x + chWidth - 1 >= this._terminal.cols) { + if (buffer.x + chWidth - 1 >= this._terminal.cols) { // autowrap - DECAWM if (this._terminal.wraparoundMode) { - this._terminal.buffer.x = 0; - this._terminal.buffer.y++; - if (this._terminal.buffer.y > this._terminal.buffer.scrollBottom) { - this._terminal.buffer.y--; + buffer.x = 0; + buffer.y++; + if (buffer.y > buffer.scrollBottom) { + buffer.y--; this._terminal.scroll(true); } else { // The line already exists (eg. the initial viewport), mark it as a // wrapped line - (this._terminal.buffer.lines.get(this._terminal.buffer.y)).isWrapped = true; + (buffer.lines.get(buffer.y)).isWrapped = true; } } else { if (chWidth === 2) { // FIXME: check for xterm behavior @@ -78,7 +82,7 @@ export class InputHandler implements IInputHandler { } } } - row = this._terminal.buffer.y + this._terminal.buffer.ybase; + row = buffer.y + buffer.ybase; // insert mode: move characters to right if (this._terminal.insertMode) { @@ -86,26 +90,26 @@ export class InputHandler implements IInputHandler { for (let moves = 0; moves < chWidth; ++moves) { // remove last cell, if it's width is 0 // we have to adjust the second last cell as well - const removed = this._terminal.buffer.lines.get(this._terminal.buffer.y + this._terminal.buffer.ybase).pop(); + const removed = buffer.lines.get(buffer.y + buffer.ybase).pop(); if (removed[CHAR_DATA_WIDTH_INDEX] === 0 - && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] - && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2][CHAR_DATA_WIDTH_INDEX] === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]; + && buffer.lines.get(row)[this._terminal.cols - 2] + && buffer.lines.get(row)[this._terminal.cols - 2][CHAR_DATA_WIDTH_INDEX] === 2) { + buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]; } // insert empty cell at cursor - this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 0, [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]); + buffer.lines.get(row).splice(buffer.x, 0, [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]); } } - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, char, chWidth, char.charCodeAt(0)]; - this._terminal.buffer.x++; - this._terminal.updateRange(this._terminal.buffer.y); + buffer.lines.get(row)[buffer.x] = [this._terminal.curAttr, char, chWidth, char.charCodeAt(0)]; + buffer.x++; + this._terminal.updateRange(buffer.y); // fullwidth char - set next cell width to zero and advance cursor if (chWidth === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, '', 0, undefined]; - this._terminal.buffer.x++; + buffer.lines.get(row)[buffer.x] = [this._terminal.curAttr, '', 0, undefined]; + buffer.x++; } } } @@ -123,17 +127,20 @@ export class InputHandler implements IInputHandler { * Line Feed or New Line (NL). (LF is Ctrl-J). */ public lineFeed(): void { + // make buffer local for faster access + const buffer = this._terminal.buffer; + if (this._terminal.convertEol) { - this._terminal.buffer.x = 0; + buffer.x = 0; } - this._terminal.buffer.y++; - if (this._terminal.buffer.y > this._terminal.buffer.scrollBottom) { - this._terminal.buffer.y--; + buffer.y++; + if (buffer.y > buffer.scrollBottom) { + buffer.y--; this._terminal.scroll(); } // If the end of the line is hit, prevent this action from wrapping around to the next line. - if (this._terminal.buffer.x >= this._terminal.cols) { - this._terminal.buffer.x--; + if (buffer.x >= this._terminal.cols) { + buffer.x--; } /** * This event is emitted whenever the terminal outputs a LF or NL. @@ -199,13 +206,16 @@ export class InputHandler implements IInputHandler { let param = params[0]; if (param < 1) param = 1; - const row = this._terminal.buffer.y + this._terminal.buffer.ybase; - let j = this._terminal.buffer.x; + // make buffer local for faster access + const buffer = this._terminal.buffer; + + const row = buffer.y + buffer.ybase; + let j = buffer.x; const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { - this._terminal.buffer.lines.get(row).splice(j++, 0, ch); - this._terminal.buffer.lines.get(row).pop(); + buffer.lines.get(row).splice(j++, 0, ch); + buffer.lines.get(row).pop(); } } @@ -447,20 +457,24 @@ export class InputHandler implements IInputHandler { if (param < 1) { param = 1; } - let row: number = this._terminal.buffer.y + this._terminal.buffer.ybase; - let scrollBottomRowsOffset = this._terminal.rows - 1 - this._terminal.buffer.scrollBottom; - let scrollBottomAbsolute = this._terminal.rows - 1 + this._terminal.buffer.ybase - scrollBottomRowsOffset + 1; + // make buffer local for faster access + const buffer = this._terminal.buffer; + + let row: number = buffer.y + buffer.ybase; + + let scrollBottomRowsOffset = this._terminal.rows - 1 - buffer.scrollBottom; + let scrollBottomAbsolute = this._terminal.rows - 1 + buffer.ybase - scrollBottomRowsOffset + 1; while (param--) { // test: echo -e '\e[44m\e[1L\e[0m' // blankLine(true) - xterm/linux behavior - this._terminal.buffer.lines.splice(scrollBottomAbsolute - 1, 1); - this._terminal.buffer.lines.splice(row, 0, this._terminal.blankLine(true)); + buffer.lines.splice(scrollBottomAbsolute - 1, 1); + buffer.lines.splice(row, 0, this._terminal.blankLine(true)); } // this.maxRange(); - this._terminal.updateRange(this._terminal.buffer.y); - this._terminal.updateRange(this._terminal.buffer.scrollBottom); + this._terminal.updateRange(buffer.y); + this._terminal.updateRange(buffer.scrollBottom); } /** @@ -472,21 +486,25 @@ export class InputHandler implements IInputHandler { if (param < 1) { param = 1; } - const row: number = this._terminal.buffer.y + this._terminal.buffer.ybase; + + // make buffer local for faster access + const buffer = this._terminal.buffer; + + const row: number = buffer.y + buffer.ybase; let j: number; - j = this._terminal.rows - 1 - this._terminal.buffer.scrollBottom; - j = this._terminal.rows - 1 + this._terminal.buffer.ybase - j; + j = this._terminal.rows - 1 - buffer.scrollBottom; + j = this._terminal.rows - 1 + buffer.ybase - j; while (param--) { // test: echo -e '\e[44m\e[1M\e[0m' // blankLine(true) - xterm/linux behavior - this._terminal.buffer.lines.splice(row, 1); - this._terminal.buffer.lines.splice(j, 0, this._terminal.blankLine(true)); + buffer.lines.splice(row, 1); + buffer.lines.splice(j, 0, this._terminal.blankLine(true)); } // this.maxRange(); - this._terminal.updateRange(this._terminal.buffer.y); - this._terminal.updateRange(this._terminal.buffer.scrollBottom); + this._terminal.updateRange(buffer.y); + this._terminal.updateRange(buffer.scrollBottom); } /** @@ -499,14 +517,17 @@ export class InputHandler implements IInputHandler { param = 1; } - const row = this._terminal.buffer.y + this._terminal.buffer.ybase; + // make buffer local for faster access + const buffer = this._terminal.buffer; + + const row = buffer.y + buffer.ybase; const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param--) { - this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 1); - this._terminal.buffer.lines.get(row).push(ch); + buffer.lines.get(row).splice(buffer.x, 1); + buffer.lines.get(row).push(ch); } - this._terminal.updateRange(this._terminal.buffer.y); + this._terminal.updateRange(buffer.y); } /** @@ -514,13 +535,17 @@ export class InputHandler implements IInputHandler { */ public scrollUp(params: number[]): void { let param = params[0] || 1; + + // make buffer local for faster access + const buffer = this._terminal.buffer; + while (param--) { - this._terminal.buffer.lines.splice(this._terminal.buffer.ybase + this._terminal.buffer.scrollTop, 1); - this._terminal.buffer.lines.splice(this._terminal.buffer.ybase + this._terminal.buffer.scrollBottom, 0, this._terminal.blankLine()); + buffer.lines.splice(buffer.ybase + buffer.scrollTop, 1); + buffer.lines.splice(buffer.ybase + buffer.scrollBottom, 0, this._terminal.blankLine()); } // this.maxRange(); - this._terminal.updateRange(this._terminal.buffer.scrollTop); - this._terminal.updateRange(this._terminal.buffer.scrollBottom); + this._terminal.updateRange(buffer.scrollTop); + this._terminal.updateRange(buffer.scrollBottom); } /** @@ -528,13 +553,17 @@ export class InputHandler implements IInputHandler { */ public scrollDown(params: number[]): void { let param = params[0] || 1; + + // make buffer local for faster access + const buffer = this._terminal.buffer; + while (param--) { - this._terminal.buffer.lines.splice(this._terminal.buffer.ybase + this._terminal.buffer.scrollBottom, 1); - this._terminal.buffer.lines.splice(this._terminal.buffer.ybase + this._terminal.buffer.scrollTop, 0, this._terminal.blankLine()); + buffer.lines.splice(buffer.ybase + buffer.scrollBottom, 1); + buffer.lines.splice(buffer.ybase + buffer.scrollTop, 0, this._terminal.blankLine()); } // this.maxRange(); - this._terminal.updateRange(this._terminal.buffer.scrollTop); - this._terminal.updateRange(this._terminal.buffer.scrollBottom); + this._terminal.updateRange(buffer.scrollTop); + this._terminal.updateRange(buffer.scrollBottom); } /** @@ -547,12 +576,15 @@ export class InputHandler implements IInputHandler { param = 1; } - const row = this._terminal.buffer.y + this._terminal.buffer.ybase; - let j = this._terminal.buffer.x; + // make buffer local for faster access + const buffer = this._terminal.buffer; + + const row = buffer.y + buffer.ybase; + let j = buffer.x; const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { - this._terminal.buffer.lines.get(row)[j++] = ch; + buffer.lines.get(row)[j++] = ch; } } @@ -561,8 +593,12 @@ export class InputHandler implements IInputHandler { */ public cursorBackwardTab(params: number[]): void { let param = params[0] || 1; + + // make buffer local for faster access + const buffer = this._terminal.buffer; + while (param--) { - this._terminal.buffer.x = this._terminal.buffer.prevStop(); + buffer.x = buffer.prevStop(); } } @@ -602,11 +638,15 @@ export class InputHandler implements IInputHandler { */ public repeatPrecedingCharacter(params: number[]): void { let param = params[0] || 1; - const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y); - const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32]; + + // make buffer local for faster access + const buffer = this._terminal.buffer; + + const line = buffer.lines.get(buffer.ybase + buffer.y); + const ch = line[buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32]; while (param--) { - line[this._terminal.buffer.x++] = ch; + line[buffer.x++] = ch; } } diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index 0e67403c..61245296 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -40,7 +40,7 @@ describe('Linkifier', () => { terminal = new MockTerminal(); terminal.cols = 100; terminal.buffer = new MockBuffer(); - terminal.buffer.lines = new CircularList(20); + (terminal.buffer).setLines(new CircularList(20)); terminal.buffer.ydisp = 0; linkifier = new TestLinkifier(terminal); mouseZoneManager = new TestMouseZoneManager(); diff --git a/src/Terminal.ts b/src/Terminal.ts index 809d0dd8..13495b2c 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -38,6 +38,7 @@ import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; import * as Browser from './shared/utils/Browser'; +import * as Dom from './utils/Dom'; import * as Strings from './Strings'; import { MouseHelper } from './utils/MouseHelper'; import { clone } from './utils/Clone'; @@ -46,7 +47,8 @@ import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; import { MouseZoneManager } from './input/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; -import { ITheme, ILocalizableStrings, IMarker } from 'xterm'; +import { ITheme, ILocalizableStrings, IMarker, IDisposable } from 'xterm'; +import { removeTerminalFromCache } from './renderer/atlas/CharAtlasCache'; // reg + shift key mappings for digits and special chars const KEYCODE_KEY_MAPPINGS = { @@ -121,15 +123,15 @@ const DEFAULT_OPTIONS: ITerminalOptions = { tabStopWidth: 8, theme: null, rightClickSelectsWord: Browser.isMac - // programFeatures: false, - // focusKeys: false, }; -export class Terminal extends EventEmitter implements ITerminal, IInputHandlingTerminal { +export class Terminal extends EventEmitter implements ITerminal, IDisposable, IInputHandlingTerminal { public textarea: HTMLTextAreaElement; public element: HTMLElement; public screenElement: HTMLElement; + private _disposables: IDisposable[]; + /** * The HTMLElement that the terminal is created in, set by Terminal.open. */ @@ -251,7 +253,28 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this._setup(); } + public dispose(): void { + super.dispose(); + this._disposables.forEach(d => d.dispose()); + this._disposables.length = 0; + removeTerminalFromCache(this); + this.handler = () => {}; + this.write = () => {}; + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + } + + /** + * @deprecated Use dispose instead. + */ + public destroy(): void { + this.dispose(); + } + private _setup(): void { + this._disposables = []; + Object.keys(DEFAULT_OPTIONS).forEach((key) => { if (this.options[key] == null) { this.options[key] = DEFAULT_OPTIONS[key]; @@ -694,7 +717,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.on('dprchange', () => this.renderer.onWindowResize(window.devicePixelRatio)); // dprchange should handle this case, we need this as well for browsers that don't support the // matchMedia query. - window.addEventListener('resize', () => this.renderer.onWindowResize(window.devicePixelRatio)); + this._disposables.push(Dom.addDisposableListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); this.charMeasure.on('charsizechanged', () => this.renderer.onResize(this.cols, this.rows)); this.renderer.on('resize', (dimensions) => this.viewport.syncScrollArea()); @@ -1087,19 +1110,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT }); } - /** - * Destroys the terminal. - */ - public destroy(): void { - super.destroy(); - this.handler = () => {}; - this.write = () => {}; - if (this.element && this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - } - // this.emit('close'); - } - /** * Tells the renderer to refresh terminal content between two rows (inclusive) at the next * opportunity. diff --git a/src/Types.ts b/src/Types.ts index 84355e1f..2442b027 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -248,7 +248,7 @@ export interface ITerminalOptions extends IPublicTerminalOptions { } export interface IBuffer { - lines: ICircularList; + readonly lines: ICircularList; ydisp: number; ybase: number; y: number; diff --git a/src/addons/attach/attach.ts b/src/addons/attach/attach.ts index b3b2e993..4a9668de 100644 --- a/src/addons/attach/attach.ts +++ b/src/addons/attach/attach.ts @@ -43,7 +43,7 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean addonTerminal.__getMessage = function(ev: MessageEvent): void { let str; - if (typeof ev.data == 'object') { + if (typeof ev.data === 'object') { if (!myTextDecoder) { myTextDecoder = new TextDecoder(); } @@ -53,14 +53,14 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean } else { let fileReader = new FileReader(); - fileReader.addEventListener('load', function() { + fileReader.addEventListener('load', () => { str = myTextDecoder.decode(this.result); displayData(str); }); fileReader.readAsArrayBuffer(ev.data); } - } else if (typeof ev.data == 'string') { - displayData(ev.data) + } else if (typeof ev.data === 'string') { + displayData(ev.data); } else { throw Error(`Cannot handle "${typeof ev.data}" websocket message.`); } @@ -73,7 +73,7 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean * @param str String decoded by FileReader. * @param data The data of the EventMessage. */ - function displayData(str?: string, data?: string) { + function displayData(str?: string, data?: string): void { if (buffered) { addonTerminal.__pushToBuffer(str || data); } else { diff --git a/src/addons/zmodem/zmodem.ts b/src/addons/zmodem/zmodem.ts index 297ebe34..4f2f4a91 100644 --- a/src/addons/zmodem/zmodem.ts +++ b/src/addons/zmodem/zmodem.ts @@ -43,8 +43,7 @@ export interface IZmodemOptions { } function zmodemAttach(ws: WebSocket, opts: IZmodemOptions = {}): void { - var term = this; - + const term = this; const senderFunc = (octets: ArrayLike) => ws.send(new Uint8Array(octets)); let zsentry; diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index edef316b..ddbdca22 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -48,23 +48,24 @@ export class TextRenderLayer extends BaseRenderLayer { this.clearAll(); } - public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void { - // Resize has not been called yet - if (this._state.cache.length === 0) { - return; - } - - this._charAtlas.beginFrame(); - - for (let y = startRow; y <= endRow; y++) { + private _forEachCell( + terminal: ITerminal, + firstRow: number, + lastRow: number, + callback: ( + code: number, + char: string, + width: number, + x: number, + y: number, + fg: number, + bg: number, + flags: number + ) => void + ): void { + for (let y = firstRow; y <= lastRow; y++) { const row = y + terminal.buffer.ydisp; const line = terminal.buffer.lines.get(row); - - this.clearCells(0, y, terminal.cols, 1); - // for (let x = 0; x < terminal.cols; x++) { - // this._state.cache[x][y] = null; - // } - for (let x = 0; x < terminal.cols; x++) { const charData = line[x]; const code: number = charData[CHAR_DATA_CODE_INDEX]; @@ -75,51 +76,12 @@ export class TextRenderLayer extends BaseRenderLayer { // The character to the left is a wide character, drawing is owned by // the char at x-1 if (width === 0) { - // this._state.cache[x][y] = null; - continue; - } - - // If the character is a space and the character to the left is an - // overlapping character, skip the character and allow the overlapping - // char to take full control over this character's cell. - if (code === 32 /*' '*/) { - if (x > 0) { - const previousChar: CharData = line[x - 1]; - if (this._isOverlapping(previousChar)) { - continue; - } - } - } - - // Skip rendering if the character is identical - // const state = this._state.cache[x][y]; - // if (state && state[CHAR_DATA_CHAR_INDEX] === char && state[CHAR_DATA_ATTR_INDEX] === attr) { - // // Skip render, contents are identical - // this._state.cache[x][y] = charData; - // continue; - // } - - // Clear the old character was not a space with the default background - // const wasInverted = !!(state && state[CHAR_DATA_ATTR_INDEX] && state[CHAR_DATA_ATTR_INDEX] >> 18 & FLAGS.INVERSE); - // if (state && !(state[CHAR_DATA_CODE_INDEX] === 32 /*' '*/ && (state[CHAR_DATA_ATTR_INDEX] & 0x1ff) >= 256 && !wasInverted)) { - // this._clearChar(x, y); - // } - // this._state.cache[x][y] = charData; - - const flags = attr >> 18; - let bg = attr & 0x1ff; - - // Skip rendering if the character is invisible - const isDefaultBackground = bg >= 256; - const isInvisible = flags & FLAGS.INVISIBLE; - const isInverted = flags & FLAGS.INVERSE; - if (!code || (code === 32 /*' '*/ && isDefaultBackground && !isInverted) || isInvisible) { continue; } // If the character is an overlapping char and the character to the right is a // space, take ownership of the cell to the right. - if (width !== 0 && this._isOverlapping(charData)) { + if (this._isOverlapping(charData)) { // If the character is overlapping, we want to force a re-render on every // frame. This is specifically to work around the case where two // overlaping chars `a` and `b` are adjacent, the cursor is moved to b and a @@ -137,10 +99,12 @@ export class TextRenderLayer extends BaseRenderLayer { } } + const flags = attr >> 18; + let bg = attr & 0x1ff; let fg = (attr >> 9) & 0x1ff; // If inverse flag is on, the foreground should become the background. - if (isInverted) { + if (flags & FLAGS.INVERSE) { const temp = bg; bg = fg; fg = temp; @@ -152,47 +116,72 @@ export class TextRenderLayer extends BaseRenderLayer { } } - // Clear the cell next to this character if it's wide - if (width === 2) { - // this.clearCells(x + 1, y, 1, 1); - } - - // Draw background - if (bg < 256) { - this._ctx.save(); - this._ctx.fillStyle = (bg === INVERTED_DEFAULT_COLOR ? this._colors.foreground.css : this._colors.ansi[bg].css); - this.fillCells(x, y, width, 1); - this._ctx.restore(); - } - - this._ctx.save(); if (flags & FLAGS.BOLD) { - this._ctx.font = this._getFont(terminal, true); // Convert the FG color to the bold variant if (fg < 8) { fg += 8; } } - if (flags & FLAGS.UNDERLINE) { - if (fg === INVERTED_DEFAULT_COLOR) { - this._ctx.fillStyle = this._colors.background.css; - } else if (fg < 256) { - // 256 color support - this._ctx.fillStyle = this._colors.ansi[fg].css; - } else { - this._ctx.fillStyle = this._colors.foreground.css; - } - this.fillBottomLineAtCells(x, y); - } - - this.drawChar(terminal, char, code, width, x, y, fg, bg, !!(flags & FLAGS.BOLD), !!(flags & FLAGS.DIM)); - - this._ctx.restore(); + callback(code, char, width, x, y, fg, bg, flags); } } } + private _drawBackground(terminal: ITerminal, firstRow: number, lastRow: number): void { + this._forEachCell(terminal, firstRow, lastRow, (code, char, width, x, y, fg, bg, flags) => { + // libvte and xterm both draw the background (but not foreground) of invisible characters, + // so we should too. + const isDefaultBackground = bg >= 256; + if (!isDefaultBackground) { + this._ctx.save(); + this._ctx.fillStyle = (bg === INVERTED_DEFAULT_COLOR ? this._colors.foreground.css : this._colors.ansi[bg].css); + this.fillCells(x, y, width, 1); + this._ctx.restore(); + } + }); + } + + private _drawForeground(terminal: ITerminal, firstRow: number, lastRow: number): void { + this._forEachCell(terminal, firstRow, lastRow, (code, char, width, x, y, fg, bg, flags) => { + if (flags & FLAGS.INVISIBLE) { + return; + } + if (flags & FLAGS.UNDERLINE) { + this._ctx.save(); + if (fg === INVERTED_DEFAULT_COLOR) { + this._ctx.fillStyle = this._colors.background.css; + } else if (fg < 256) { + // 256 color support + this._ctx.fillStyle = this._colors.ansi[fg].css; + } else { + this._ctx.fillStyle = this._colors.foreground.css; + } + this.fillBottomLineAtCells(x, y); + this._ctx.restore(); + } + this.drawChar( + terminal, char, code, + width, x, y, + fg, bg, + !!(flags & FLAGS.BOLD), !!(flags & FLAGS.DIM) + ); + }); + } + + public onGridChanged(terminal: ITerminal, firstRow: number, lastRow: number): void { + // Resize has not been called yet + if (this._state.cache.length === 0) { + return; + } + + this._charAtlas.beginFrame(); + + this.clearCells(0, firstRow, terminal.cols, lastRow - firstRow + 1); + this._drawBackground(terminal, firstRow, lastRow); + this._drawForeground(terminal, firstRow, lastRow); + } + public onOptionsChanged(terminal: ITerminal): void { this.setTransparency(terminal, terminal.options.allowTransparency); } diff --git a/src/renderer/atlas/CharAtlasCache.ts b/src/renderer/atlas/CharAtlasCache.ts index c44f718a..19e5497a 100644 --- a/src/renderer/atlas/CharAtlasCache.ts +++ b/src/renderer/atlas/CharAtlasCache.ts @@ -42,6 +42,8 @@ export function acquireCharAtlas( ): BaseCharAtlas { const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); + // TODO: Currently if a terminal changes configs it will not free the entry reference (until it's disposed) + // Check to see if the terminal already owns this config for (let i = 0; i < charAtlasCache.length; i++) { const entry = charAtlasCache[i]; @@ -82,3 +84,23 @@ export function acquireCharAtlas( charAtlasCache.push(newEntry); return newEntry.atlas; } + +/** + * Removes a terminal reference from the cache, allowing its memory to be freed. + * @param terminal The terminal to remove. + */ +export function removeTerminalFromCache(terminal: ITerminal): void { + for (let i = 0; i < charAtlasCache.length; i++) { + const index = charAtlasCache[i].ownedBy.indexOf(terminal); + if (index !== -1) { + if (charAtlasCache[i].ownedBy.length === 1) { + // Remove the cache entry if it's the only terminal + charAtlasCache.splice(i, 1); + } else { + // Remove the reference from the cache entry + charAtlasCache[i].ownedBy.splice(index, 1); + } + break; + } + } +} diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 5f3a8482..51aafb50 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -63,6 +63,9 @@ export class MockTerminal implements ITerminal { selectAll(): void { throw new Error('Method not implemented.'); } + dispose(): void { + throw new Error('Method not implemented.'); + } destroy(): void { throw new Error('Method not implemented.'); } @@ -306,6 +309,9 @@ export class MockBuffer implements IBuffer { prevStop(x?: number): number { throw new Error('Method not implemented.'); } + setLines(lines: ICircularList<[number, string, number, number][]>): void { + this.lines = lines; + } } export class MockRenderer implements IRenderer { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index f7898d6e..f549180c 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -269,7 +269,7 @@ declare module 'xterm' { /** * The class that represents an xterm.js terminal. */ - export class Terminal implements IEventEmitter { + export class Terminal implements IEventEmitter, IDisposable { /** * The element containing the terminal. */ @@ -469,8 +469,16 @@ declare module 'xterm' { */ selectLines(start: number, end: number): void; + /* + * Disposes of the terminal, detaching it from the DOM and removing any + * active listeners. + */ + dispose(): void; + /** * Destroys the terminal and detaches it from the DOM. + * + * @deprecated Use dispose() instead. */ destroy(): void;