diff --git a/.npmignore b/.npmignore index 63069bda..517ba0bd 100644 --- a/.npmignore +++ b/.npmignore @@ -1,16 +1,48 @@ -node_modules/ -*.swp -.lock-wscript -lib/*.test.js -lib/*.test.js.map +# Blacklist - exclude everything except npm defaults such as LICENSE, etc +* +!*/ + +# Whitelist - entries to be included must be negated with "!" +!*.js +!*.json + +# Whitelist - dist/ +!dist/**/*.js +!dist/**/*.js.map + +!dist/**/*.css + +# Whitelist - lib/ +!lib/**/*.d.ts + +!lib/**/*.js +!lib/**/*.js.map + +!lib/**/*.css + +# Whitelist - src/ +!src/**/*.ts +!src/**/*.d.ts + +!src/**/*.js +!src/**/*.js.map + +!src/**/*.css + +# Whitelist - typings/ +!typings/*.d.ts + +# Blacklist - (normal behavior) these will override any whitelist +*.test.ts +*.test.d.ts +*.test.js +*.test.js.map lib/test/ -Makefile.gyp -*.Makefile -*.target.gyp.mk -*.node -example/*.log + docs/ -npm-debug.log /.idea/ -.env +.vscode/ build/ +fixtures/ +coverage/ +demo/ diff --git a/.travis.yml b/.travis.yml index 2833b7ce..c212b19d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,4 +17,3 @@ env: notifications: email: false script: npm run $NPM_COMMAND -after_success: npm run coveralls diff --git a/AUTHORS b/AUTHORS index 41f3a523..0fed60b3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Benjamin Woodruff Bill Church Bob Reid bottleofwater +Brandon Bayer Brian Mock Bruno Ribeiro Bruno Ribeito @@ -62,12 +63,16 @@ Jianhui Zhao Joao Moreno Joao Moreno Johannes Zellner +Jon Austin Jon Masters Jörg Breitbart +jpoth Justin Luk Justin Mecham Kirill Merkushev Krasimir Tsonev +Ledion Bitincka +Linus Unnebäck Luca Lucian Buzzo Lukas Drgon @@ -88,8 +93,11 @@ npezza93 Oleksandr Andriienko Paris Kasidiaris Paris Kasidiaris +Peng Xiao Peter Baumgarten Philip Olson +pro-src <34285059+pro-src@users.noreply.github.com> +pro-src Rick Baker runarberg Saad Malik diff --git a/README.md b/README.md index a113dfe8..6790fea9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [![xterm.js logo](logo-full.png)](https://xtermjs.org) -[![xterm.js build status](https://api.travis-ci.org/xtermjs/xterm.js.svg)](https://travis-ci.org/xtermjs/xterm.js) [![Coverage Status](https://coveralls.io/repos/github/sourcelair/xterm.js/badge.svg)](https://coveralls.io/github/sourcelair/xterm.js) [![Gitter](https://badges.gitter.im/sourcelair/xterm.js.svg)](https://gitter.im/sourcelair/xterm.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![jsDelivr Hits](https://data.jsdelivr.com/v1/package/npm/xterm/badge?style=rounded)](https://www.jsdelivr.com/package/npm/xterm) +[![xterm.js build status](https://api.travis-ci.org/xtermjs/xterm.js.svg)](https://travis-ci.org/xtermjs/xterm.js) [![Coverage Status](https://coveralls.io/repos/github/xtermjs/xterm.js/badge.svg?branch=master)](https://coveralls.io/github/xtermjs/xterm.js?branch=master) [![Gitter](https://badges.gitter.im/sourcelair/xterm.js.svg)](https://gitter.im/sourcelair/xterm.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![jsDelivr Hits](https://data.jsdelivr.com/v1/package/npm/xterm/badge?style=rounded)](https://www.jsdelivr.com/package/npm/xterm) Xterm.js is a terminal front-end component written in JavaScript that works in the browser. @@ -41,7 +41,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t @@ -57,8 +57,6 @@ The proposed way to load xterm.js is via the ES6 module syntax. import { Terminal } from 'xterm'; ``` -*Note: There are currently no typings for addons so you will need to upcast if using TypeScript, eg. `(xterm).fit()`.* - ### Addons Addons are JavaScript modules that extend the `Terminal` prototype with new methods and attributes to provide additional functionality. There are a handful available in the main repository in the `src/addons` directory and you can even write your own, by using xterm.js' public API. @@ -76,6 +74,27 @@ var xterm = new Terminal(); // Instantiate the terminal xterm.fit(); // Use the `fit` method, provided by the `fit` addon ``` +#### Importing Addons in TypeScript + +There are currently no typings for addons if they are accessed via extending Terminal prototype, so you will need to upcast if using TypeScript, eg. `(xterm).fit()`. + +Alternatively, you can import addon function and enhance the terminal on demand. This would have better typing support and is friendly to treeshaking. E.g.: + +```typescript +import { Terminal } from 'xterm'; +import { fit } from 'xterm/lib/addons/fit/fit'; +const xterm = new Terminal(); + +// Fit the terminal when necessary: +fit(xterm); +``` + +#### 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 +147,8 @@ 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 +- [**Diag**](https://diag.ai): A better way to troubleshoot problems faster. Capture, share and reapply troubleshooting knowledge so you can focus on solving problems that matter. 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/demo/index.html b/demo/index.html index 760d5990..168f56e4 100644 --- a/demo/index.html +++ b/demo/index.html @@ -9,7 +9,7 @@ -

xterm.js: xterm, in the browser

+

xterm.js: A terminal for the web

Actions

@@ -26,6 +26,9 @@

+

+ +

+

+ +

Size

diff --git a/demo/main.js b/demo/main.js index d9c0151a..682fc5a0 100644 --- a/demo/main.js +++ b/demo/main.js @@ -31,7 +31,9 @@ var terminalContainer = document.getElementById('terminal-container'), cursorStyle: document.querySelector('#option-cursor-style'), macOptionIsMeta: document.querySelector('#option-mac-option-is-meta'), scrollback: document.querySelector('#option-scrollback'), + transparency: document.querySelector('#option-transparency'), tabstopwidth: document.querySelector('#option-tabstopwidth'), + experimentalCharAtlas: document.querySelector('#option-experimental-char-atlas'), bellStyle: document.querySelector('#option-bell-style'), screenReaderMode: document.querySelector('#option-screen-reader-mode') }, @@ -74,21 +76,29 @@ actionElements.findPrevious.addEventListener('keypress', function (e) { optionElements.cursorBlink.addEventListener('change', function () { term.setOption('cursorBlink', optionElements.cursorBlink.checked); }); +optionElements.macOptionIsMeta.addEventListener('change', function () { + term.setOption('macOptionIsMeta', optionElements.macOptionIsMeta.checked); +}); +optionElements.transparency.addEventListener('change', function () { + var checked = optionElements.transparency.checked; + term.setOption('allowTransparency', checked); + term.setOption('theme', checked ? {background: 'rgba(0, 0, 0, .5)'} : {}); +}); optionElements.cursorStyle.addEventListener('change', function () { term.setOption('cursorStyle', optionElements.cursorStyle.value); }); optionElements.bellStyle.addEventListener('change', function () { term.setOption('bellStyle', optionElements.bellStyle.value); }); -optionElements.macOptionIsMeta.addEventListener('change', function () { - term.setOption('macOptionIsMeta', optionElements.macOptionIsMeta.checked); -}); optionElements.scrollback.addEventListener('change', function () { term.setOption('scrollback', parseInt(optionElements.scrollback.value, 10)); }); optionElements.tabstopwidth.addEventListener('change', function () { term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10)); }); +optionElements.experimentalCharAtlas.addEventListener('change', function () { + term.setOption('experimentalCharAtlas', optionElements.experimentalCharAtlas.value); +}); optionElements.screenReaderMode.addEventListener('change', function () { term.setOption('screenReaderMode', optionElements.screenReaderMode.checked); }); diff --git a/package.json b/package.json index 7e4462fb..45f35158 100644 --- a/package.json +++ b/package.json @@ -1,48 +1,16 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "3.3.0", - "ignore": [ - "demo", - "test", - ".gitignore" - ], + "version": "3.4.0", "main": "lib/Terminal.js", "types": "typings/xterm.d.ts", "repository": "https://github.com/xtermjs/xterm.js", "license": "MIT", - "files": [ - "*.js", - "*.json", - "dist/*.css", - "dist/**/*.css", - "dist/*.js", - "dist/*.js.map", - "dist/**/*.js", - "dist/**/*.js.map", - "lib/*.css", - "lib/**/*.css", - "lib/*.d.ts", - "lib/*.js", - "lib/*.js.map", - "lib/**/*.d.ts", - "lib/**/*.js", - "lib/**/*.js.map", - "src/*.css", - "src/**/*.css", - "src/*.js", - "src/*.js.map", - "src/*.ts", - "src/**/*.js", - "src/**/*.js.map", - "src/**/*.ts", - "typings/*.d.ts" - ], "devDependencies": { "@types/chai": "^3.4.34", "@types/jsdom": "^11.0.1", "@types/mocha": "^2.2.33", - "@types/node": "^6.0.41", + "@types/node": "6.0.108", "@types/text-encoding": "0.0.32", "browserify": "^13.3.0", "chai": "3.5.0", @@ -67,6 +35,7 @@ "npm-run-all": "^4.1.2", "sorcery": "^0.10.0", "tslint": "^5.9.1", + "tslint-consistent-codestyle": "^1.13.0", "typescript": "~2.7.1", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", @@ -77,7 +46,7 @@ "scripts": { "start": "node demo/app", "start-zmodem": "node demo/zmodem/app", - "lint": "tslint src/*.ts src/**/*.ts", + "lint": "tslint 'src/**/*.ts'", "test": "npm-run-all mocha lint", "test-debug": "node --inspect-brk node_modules/.bin/gulp test", "test-suite": "gulp mocha-suite --test", @@ -90,6 +59,5 @@ "coveralls": "gulp coveralls", "webpack": "gulp webpack", "watch": "gulp watch" - }, - "dependencies": {} + } } diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 5a1f4770..a7e205e3 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -12,9 +12,9 @@ import { IDisposable } from 'xterm'; const MAX_ROWS_TO_READ = 20; -enum BoundaryPosition { - Top, - Bottom +const enum BoundaryPosition { + TOP, + BOTTOM } export class AccessibilityManager implements IDisposable { @@ -54,8 +54,8 @@ export class AccessibilityManager implements IDisposable { this._rowContainer.appendChild(this._rowElements[i]); } - this._topBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Top); - this._bottomBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Bottom); + this._topBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.TOP); + this._bottomBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.BOTTOM); this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); @@ -101,11 +101,11 @@ export class AccessibilityManager implements IDisposable { private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void { const boundaryElement = e.target; - const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2]; + const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2]; // Don't scroll if the buffer top has reached the end in that direction const posInSet = boundaryElement.getAttribute('aria-posinset'); - const lastRowPos = position === BoundaryPosition.Top ? '1' : `${this._terminal.buffer.lines.length}`; + const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`; if (posInSet === lastRowPos) { return; } @@ -119,7 +119,7 @@ export class AccessibilityManager implements IDisposable { // Remove old boundary element from array let topBoundaryElement: HTMLElement; let bottomBoundaryElement: HTMLElement; - if (position === BoundaryPosition.Top) { + if (position === BoundaryPosition.TOP) { topBoundaryElement = boundaryElement; bottomBoundaryElement = this._rowElements.pop()!; this._rowContainer.removeChild(bottomBoundaryElement); @@ -134,7 +134,7 @@ export class AccessibilityManager implements IDisposable { bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); // Add new element to array/DOM - if (position === BoundaryPosition.Top) { + if (position === BoundaryPosition.TOP) { const newElement = this._createAccessibilityTreeNode(); this._rowElements.unshift(newElement); this._rowContainer.insertAdjacentElement('afterbegin', newElement); @@ -149,10 +149,10 @@ export class AccessibilityManager implements IDisposable { this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); // Scroll up - this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1); + this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1); // Focus new boundary before element - this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus(); + this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus(); // Prevent the standard behavior e.preventDefault(); 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/CompositionHelper.ts b/src/CompositionHelper.ts index 389cb782..b721b7f1 100644 --- a/src/CompositionHelper.ts +++ b/src/CompositionHelper.ts @@ -92,11 +92,10 @@ export class CompositionHelper { } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { // Continue composing if the keyCode is a modifier key return false; - } else { - // Finish composition immediately. This is mainly here for the case where enter is - // pressed and the handler needs to be triggered before the command is executed. - this._finalizeComposition(false); } + // Finish composition immediately. This is mainly here for the case where enter is + // pressed and the handler needs to be triggered before the command is executed. + this._finalizeComposition(false); } if (ev.keyCode === 229) { 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..7696324e 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; } } @@ -1223,6 +1263,9 @@ export class InputHandler implements IInputHandler { } else if (p === 1) { // bold text flags |= FLAGS.BOLD; + } else if (p === 3) { + // italic text + flags |= FLAGS.ITALIC; } else if (p === 4) { // underlined text flags |= FLAGS.UNDERLINE; 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/Parser.ts b/src/Parser.ts index 21e5a612..372d8443 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -150,7 +150,7 @@ csiStateHandler['s'] = (handler, params) => handler.saveCursor(params); csiStateHandler['u'] = (handler, params) => handler.restoreCursor(params); csiStateHandler[C0.CAN] = (handler, params, prefix, postfix, parser) => parser.setState(ParserState.NORMAL); -export enum ParserState { +export const enum ParserState { NORMAL = 0, ESCAPED = 1, CSI_PARAM = 2, diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index a50203bd..93da887a 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -54,7 +54,7 @@ interface IWordPosition { /** * A selection mode, this drives how the selection behaves on mouse move. */ -enum SelectionMode { +const enum SelectionMode { NORMAL, WORD, LINE diff --git a/src/Terminal.ts b/src/Terminal.ts index 7995c3a7..fc3998d8 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 = { @@ -101,7 +103,9 @@ const DEFAULT_OPTIONS: ITerminalOptions = { cursorStyle: 'block', bellSound: DEFAULT_BELL_SOUND, bellStyle: 'none', + drawBoldTextInBrightColors: true, enableBold: true, + experimentalCharAtlas: 'static', fontFamily: 'courier-new, courier, monospace', fontSize: 15, fontWeight: 'normal', @@ -120,15 +124,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. */ @@ -250,7 +254,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]; @@ -449,21 +474,28 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT case 'fontFamily': case 'fontSize': // When the font changes the size of the cells may change which requires a renderer clear - this.renderer.clear(); - this.charMeasure.measure(this.options); + if (this.renderer) { + this.renderer.clear(); + this.charMeasure.measure(this.options); + } break; + case 'experimentalCharAtlas': case 'enableBold': case 'letterSpacing': case 'lineHeight': case 'fontWeight': case 'fontWeightBold': // When the font changes the size of the cells may change which requires a renderer clear - this.renderer.clear(); - this.renderer.onResize(this.cols, this.rows); - this.refresh(0, this.rows - 1); + if (this.renderer) { + this.renderer.clear(); + this.renderer.onResize(this.cols, this.rows); + this.refresh(0, this.rows - 1); + } case 'scrollback': this.buffers.resize(this.cols, this.rows); - this.viewport.syncScrollArea(); + if (this.viewport) { + this.viewport.syncScrollArea(); + } break; case 'screenReaderMode': if (value) { @@ -686,7 +718,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()); @@ -1079,19 +1111,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..15498ce1 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -17,7 +17,7 @@ export type LineData = CharData[]; export type LinkMatcherHandler = (event: MouseEvent, uri: string) => void; export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void; -export enum LinkHoverEventTypes { +export const enum LinkHoverEventTypes { HOVER = 'linkhover', TOOLTIP = 'linktooltip', LEAVE = 'linkleave' @@ -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/handlers/AltClickHandler.ts b/src/handlers/AltClickHandler.ts index f77637ea..7f2e4f05 100644 --- a/src/handlers/AltClickHandler.ts +++ b/src/handlers/AltClickHandler.ts @@ -6,11 +6,11 @@ import { ITerminal, ICircularList, LineData } from '../Types'; import { C0 } from '../EscapeSequences'; -enum Direction { - Up = 'A', - Down = 'B', - Right = 'C', - Left = 'D' +const enum Direction { + UP = 'A', + DOWN = 'B', + RIGHT = 'C', + LEFT = 'D' } export class AltClickHandler { @@ -28,7 +28,7 @@ export class AltClickHandler { this._startCol = this._terminal.buffer.x; this._startRow = this._terminal.buffer.y; - [this._endCol, this._endRow] = this._terminal.mouseHelper.getCoords( + let coordinates = this._terminal.mouseHelper.getCoords( this._mouseEvent, this._terminal.element, this._terminal.charMeasure, @@ -36,16 +36,20 @@ export class AltClickHandler { this._terminal.cols, this._terminal.rows, false - ).map((coordinate: number) => { - return coordinate - 1; - }); + ); + + if (coordinates) { + [this._endCol, this._endRow] = coordinates.map((coordinate: number) => { + return coordinate - 1; + }); + } } /** * Writes the escape sequences of arrows to the terminal */ public move(): void { - if (this._mouseEvent.altKey) { + if (this._mouseEvent.altKey && this._endCol !== undefined && this._endRow !== undefined) { this._terminal.send(this._arrowSequences()); } } @@ -73,12 +77,11 @@ export class AltClickHandler { private _resetStartingRow(): string { if (this._moveToRequestedRow().length === 0) { return ''; - } else { - return repeat(this._bufferLine( - this._startCol, this._startRow, this._startCol, - this._startRow - this._wrappedRowsForRow(this._startRow), false - ).length, this._sequence(Direction.Left)); } + return repeat(this._bufferLine( + this._startCol, this._startRow, this._startCol, + this._startRow - this._wrappedRowsForRow(this._startRow), false + ).length, this._sequence(Direction.LEFT)); } /** @@ -110,7 +113,7 @@ export class AltClickHandler { return repeat(this._bufferLine( this._startCol, startRow, this._endCol, endRow, - direction === Direction.Right + direction === Direction.RIGHT ).length, this._sequence(direction)); } @@ -133,7 +136,7 @@ export class AltClickHandler { let endRow = this._endRow - this._wrappedRowsForRow(this._endRow); for (let i = 0; i < Math.abs(startRow - endRow); i++) { - let direction = this._verticalDirection() === Direction.Up ? -1 : 1; + let direction = this._verticalDirection() === Direction.UP ? -1 : 1; if ((this._lines.get(startRow + (direction * i))).isWrapped) { wrappedRows++; @@ -179,10 +182,9 @@ export class AltClickHandler { startRow <= this._endRow) || // down/right or same y/right (this._startCol >= this._endCol && startRow < this._endRow)) { // down/left or same y/left - return Direction.Right; - } else { - return Direction.Left; + return Direction.RIGHT; } + return Direction.LEFT; } /** @@ -190,10 +192,9 @@ export class AltClickHandler { */ private _verticalDirection(): Direction { if (this._startRow > this._endRow) { - return Direction.Up; - } else { - return Direction.Down; + return Direction.UP; } + return Direction.DOWN; } /** diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index fbffd547..b2a40290 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -6,8 +6,8 @@ import { IRenderLayer, IColorSet, IRenderDimensions } from './Types'; import { CharData, ITerminal } from '../Types'; import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from './atlas/Types'; -import { CHAR_ATLAS_CELL_SPACING } from '../shared/atlas/Types'; -import { acquireCharAtlas } from './atlas/CharAtlas'; +import BaseCharAtlas from './atlas/BaseCharAtlas'; +import { acquireCharAtlas } from './atlas/CharAtlasCache'; import { CHAR_DATA_CHAR_INDEX } from '../Buffer'; export abstract class BaseRenderLayer implements IRenderLayer { @@ -20,7 +20,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { private _scaledCharLeft: number = 0; private _scaledCharTop: number = 0; - private _charAtlas: HTMLCanvasElement | ImageBitmap; + protected _charAtlas: BaseCharAtlas; constructor( private _container: HTMLElement, @@ -83,13 +83,8 @@ export abstract class BaseRenderLayer implements IRenderLayer { if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) { return; } - this._charAtlas = null; - const result = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); - if (result instanceof HTMLCanvasElement) { - this._charAtlas = result; - } else { - result.then(bitmap => this._charAtlas = bitmap); - } + this._charAtlas = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); + this._charAtlas.warmUp(); } public resize(terminal: ITerminal, dim: IRenderDimensions): void { @@ -219,7 +214,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param color The color of the character. */ protected fillCharTrueColor(terminal: ITerminal, charData: CharData, x: number, y: number): void { - this._ctx.font = this._getFont(terminal, false); + this._ctx.font = this._getFont(terminal, false, false); this._ctx.textBaseline = 'top'; this._clipRow(terminal, y); this._ctx.fillText( @@ -242,56 +237,19 @@ export abstract class BaseRenderLayer implements IRenderLayer { * This is used to validate whether a cached image can be used. * @param bold Whether the text is bold. */ - protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean): void { - let colorIndex = 0; - if (fg < 256) { - colorIndex = fg + 2; - } else { - // If default color and bold - if (bold && terminal.options.enableBold) { - colorIndex = 1; - } + protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean, italic: boolean): void { + const drawInBrightColor = terminal.options.drawBoldTextInBrightColors && bold && fg < 8; + fg += drawInBrightColor ? 8 : 0; + const atlasDidDraw = this._charAtlas && this._charAtlas.draw( + this._ctx, + {char, code, bg, fg, bold: bold && terminal.options.enableBold, dim, italic}, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + ); + + if (!atlasDidDraw) { + this._drawUncachedChar(terminal, char, width, fg, x, y, bold && terminal.options.enableBold, dim, italic); } - const isAscii = code < 256; - // A color is basic if it is one of the standard normal or bold weight - // colors of the characters held in the char atlas. Note that this excludes - // the normal weight _light_ color characters. - const isBasicColor = (colorIndex > 1 && fg < 16) && (fg < 8 || bold); - const isDefaultColor = fg >= 256; - const isDefaultBackground = bg >= 256; - if (this._charAtlas && isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground) { - // ImageBitmap's draw about twice as fast as from a canvas - const charAtlasCellWidth = this._scaledCharWidth + CHAR_ATLAS_CELL_SPACING; - const charAtlasCellHeight = this._scaledCharHeight + CHAR_ATLAS_CELL_SPACING; - - // Apply alpha to dim the character - if (dim) { - this._ctx.globalAlpha = DIM_OPACITY; - } - - // Draw the non-bold version of the same color if bold is not enabled - if (bold && !terminal.options.enableBold) { - // Ignore default color as it's not touched above - if (colorIndex > 1) { - colorIndex -= 8; - } - } - - this._ctx.drawImage(this._charAtlas, - code * charAtlasCellWidth, - colorIndex * charAtlasCellHeight, - charAtlasCellWidth, - this._scaledCharHeight, - x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop, - charAtlasCellWidth, - this._scaledCharHeight); - } else { - this._drawUncachedChar(terminal, char, width, fg, x, y, bold && terminal.options.enableBold, dim); - } - // This draws the atlas (for debugging purposes) - // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); - // this._ctx.drawImage(this._charAtlas, 0, 0); } /** @@ -305,9 +263,9 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to draw at. * @param y The row to draw at. */ - private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean, dim: boolean): void { + private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean, dim: boolean, italic: boolean): void { this._ctx.save(); - this._ctx.font = this._getFont(terminal, bold); + this._ctx.font = this._getFont(terminal, bold, italic); this._ctx.textBaseline = 'top'; if (fg === INVERTED_DEFAULT_COLOR) { @@ -353,10 +311,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param terminal The terminal. * @param isBold If we should use the bold fontWeight. */ - protected _getFont(terminal: ITerminal, isBold: boolean): string { + protected _getFont(terminal: ITerminal, isBold: boolean, isItalic: boolean): string { const fontWeight = isBold ? terminal.options.fontWeightBold : terminal.options.fontWeight; + const fontStyle = isItalic ? 'italic' : ''; - return `${fontWeight} ${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; + return `${fontStyle} ${fontWeight} ${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; } } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 9404a461..c41dece5 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -150,6 +150,7 @@ export class Renderer extends EventEmitter implements IRenderer { } public onOptionsChanged(): void { + this.colorManager.allowTransparency = this._terminal.options.allowTransparency; this._runOperation(l => l.onOptionsChanged(this._terminal)); } diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index 2487a294..d6f96936 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -32,7 +32,7 @@ export class TextRenderLayer extends BaseRenderLayer { super.resize(terminal, dim); // Clear the character width cache if the font or width has changed - const terminalFont = this._getFont(terminal, false); + const terminalFont = this._getFont(terminal, false, false); if (this._characterWidth !== dim.scaledCharWidth || this._characterFont !== terminalFont) { this._characterWidth = dim.scaledCharWidth; this._characterFont = terminalFont; @@ -48,21 +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; - } - - 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]; @@ -73,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 @@ -135,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; @@ -150,47 +116,105 @@ 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); } } } + /** + * Draws the background for a specified range of columns. Tries to batch adjacent cells of the + * same color together to reduce draw calls. + */ + private _drawBackground(terminal: ITerminal, firstRow: number, lastRow: number): void { + const ctx = this._ctx; + const cols = terminal.cols; + let startX: number = 0; + let startY: number = 0; + let prevFillStyle: string | null = null; + + ctx.save(); + + 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. + let nextFillStyle = null; // null represents default background color + if (bg === INVERTED_DEFAULT_COLOR) { + nextFillStyle = this._colors.foreground.css; + } else if (bg < 256) { + nextFillStyle = this._colors.ansi[bg].css; + } + + if (prevFillStyle === null) { + // This is either the first iteration, or the default background was set. Either way, we + // don't need to draw anything. + startX = x; + startY = y; + } if (y !== startY) { + // our row changed, draw the previous row + ctx.fillStyle = prevFillStyle; + this.fillCells(startX, startY, cols - startX, 1); + startX = x; + startY = y; + } else if (prevFillStyle !== nextFillStyle) { + // our color changed, draw the previous characters in this row + ctx.fillStyle = prevFillStyle; + this.fillCells(startX, startY, x - startX, 1); + startX = x; + startY = y; + } + + prevFillStyle = nextFillStyle; + }); + + // flush the last color we encountered + if (prevFillStyle !== null) { + ctx.fillStyle = prevFillStyle; + this.fillCells(startX, startY, cols - startX, 1); + } + + 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), !!(flags & FLAGS.ITALIC) + ); + }); + } + + 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/Types.ts b/src/renderer/Types.ts index 8c464bec..edecf8b0 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -10,13 +10,14 @@ import { IColorSet } from '../shared/Types'; /** * Flags used to render terminal text properly. */ -export enum FLAGS { +export const enum FLAGS { BOLD = 1, UNDERLINE = 2, BLINK = 4, INVERSE = 8, INVISIBLE = 16, - DIM = 32 + DIM = 32, + ITALIC = 64 } export interface IRenderer extends IEventEmitter { diff --git a/src/renderer/atlas/BaseCharAtlas.ts b/src/renderer/atlas/BaseCharAtlas.ts new file mode 100644 index 00000000..50d35faa --- /dev/null +++ b/src/renderer/atlas/BaseCharAtlas.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IGlyphIdentifier } from './Types'; + +export default abstract class BaseCharAtlas { + private _didWarmUp: boolean = false; + + /** + * Perform any work needed to warm the cache before it can be used. May be called multiple times. + * Implement _doWarmUp instead if you only want to get called once. + */ + public warmUp(): void { + if (!this._didWarmUp) { + this._doWarmUp(); + this._didWarmUp = true; + } + } + + /** + * Perform any work needed to warm the cache before it can be used. Used by the default + * implementation of warmUp(), and will only be called once. + */ + protected _doWarmUp(): void { } + + /** + * Called when we start drawing a new frame. + * + * TODO: We rely on this getting called by TextRenderLayer. This should really be called by + * Renderer instead, but we need to make Renderer the source-of-truth for the char atlas, instead + * of BaseRenderLayer. + */ + public beginFrame(): void { } + + /** + * May be called before warmUp finishes, however it is okay for the implementation to + * do nothing and return false in that case. + * + * @param ctx Where to draw the character onto. + * @param glyph Information about what to draw + * @param x The position on the context to start drawing at + * @param y The position on the context to start drawing at + * @returns The success state. True if we drew the character. + */ + public abstract draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean; +} diff --git a/src/renderer/atlas/CharAtlas.ts b/src/renderer/atlas/CharAtlas.ts deleted file mode 100644 index 2e417c09..00000000 --- a/src/renderer/atlas/CharAtlas.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { ITerminal } from '../../Types'; -import { IColorSet } from '../Types'; -import { ICharAtlasConfig } from '../../shared/atlas/Types'; -import { generateCharAtlas } from '../../shared/atlas/CharAtlasGenerator'; -import { generateConfig, configEquals } from './CharAtlasUtils'; - -interface ICharAtlasCacheEntry { - bitmap: HTMLCanvasElement | Promise; - config: ICharAtlasConfig; - ownedBy: ITerminal[]; -} - -let charAtlasCache: ICharAtlasCacheEntry[] = []; - -/** - * Acquires a char atlas, either generating a new one or returning an existing - * one that is in use by another terminal. - * @param terminal The terminal. - * @param colors The colors to use. - */ -export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledCharWidth: number, scaledCharHeight: number): HTMLCanvasElement | Promise { - const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); - - // Check to see if the terminal already owns this config - for (let i = 0; i < charAtlasCache.length; i++) { - const entry = charAtlasCache[i]; - const ownedByIndex = entry.ownedBy.indexOf(terminal); - if (ownedByIndex >= 0) { - if (configEquals(entry.config, newConfig)) { - return entry.bitmap; - } else { - // The configs differ, release the terminal from the entry - if (entry.ownedBy.length === 1) { - charAtlasCache.splice(i, 1); - } else { - entry.ownedBy.splice(ownedByIndex, 1); - } - break; - } - } - } - - // Try match a char atlas from the cache - for (let i = 0; i < charAtlasCache.length; i++) { - const entry = charAtlasCache[i]; - if (configEquals(entry.config, newConfig)) { - // Add the terminal to the cache entry and return - entry.ownedBy.push(terminal); - return entry.bitmap; - } - } - - const canvasFactory = (width: number, height: number) => { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - return canvas; - }; - - const newEntry: ICharAtlasCacheEntry = { - bitmap: generateCharAtlas(window, canvasFactory, newConfig), - config: newConfig, - ownedBy: [terminal] - }; - charAtlasCache.push(newEntry); - return newEntry.bitmap; -} diff --git a/src/renderer/atlas/CharAtlasCache.ts b/src/renderer/atlas/CharAtlasCache.ts new file mode 100644 index 00000000..eee93d6c --- /dev/null +++ b/src/renderer/atlas/CharAtlasCache.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from '../../Types'; +import { IColorSet } from '../Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import { generateConfig, configEquals } from './CharAtlasUtils'; +import BaseCharAtlas from './BaseCharAtlas'; +import DynamicCharAtlas from './DynamicCharAtlas'; +import NoneCharAtlas from './NoneCharAtlas'; +import StaticCharAtlas from './StaticCharAtlas'; + +const charAtlasImplementations = { + 'none': NoneCharAtlas, + 'static': StaticCharAtlas, + 'dynamic': DynamicCharAtlas +}; + +interface ICharAtlasCacheEntry { + atlas: BaseCharAtlas; + config: ICharAtlasConfig; + // N.B. This implementation potentially holds onto copies of the terminal forever, so + // this may cause memory leaks. + ownedBy: ITerminal[]; +} + +const charAtlasCache: ICharAtlasCacheEntry[] = []; + +/** + * Acquires a char atlas, either generating a new one or returning an existing + * one that is in use by another terminal. + * @param terminal The terminal. + * @param colors The colors to use. + */ +export function acquireCharAtlas( + terminal: ITerminal, + colors: IColorSet, + scaledCharWidth: number, + scaledCharHeight: number +): 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]; + const ownedByIndex = entry.ownedBy.indexOf(terminal); + if (ownedByIndex >= 0) { + if (configEquals(entry.config, newConfig)) { + return entry.atlas; + } + // The configs differ, release the terminal from the entry + if (entry.ownedBy.length === 1) { + charAtlasCache.splice(i, 1); + } else { + entry.ownedBy.splice(ownedByIndex, 1); + } + break; + } + } + + // Try match a char atlas from the cache + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + if (configEquals(entry.config, newConfig)) { + // Add the terminal to the cache entry and return + entry.ownedBy.push(terminal); + return entry.atlas; + } + } + + const newEntry: ICharAtlasCacheEntry = { + atlas: new charAtlasImplementations[terminal.options.experimentalCharAtlas]( + document, + newConfig + ), + config: newConfig, + ownedBy: [terminal] + }; + 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/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts index 24ed5a48..39284d32 100644 --- a/src/renderer/atlas/CharAtlasUtils.ts +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -8,15 +8,19 @@ import { IColorSet } from '../Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { + // null out some fields that don't matter const clonedColors = { foreground: colors.foreground, background: colors.background, cursor: null, cursorAccent: null, selection: null, + // For the static char atlas, we only use the first 16 colors, but we need all 256 for the + // dynamic character atlas. ansi: colors.ansi.slice(0, 16) }; return { + type: terminal.options.experimentalCharAtlas, devicePixelRatio: window.devicePixelRatio, scaledCharWidth, scaledCharHeight, @@ -35,7 +39,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean return false; } } - return a.devicePixelRatio === b.devicePixelRatio && + return a.type === b.type && + a.devicePixelRatio === b.devicePixelRatio && a.fontFamily === b.fontFamily && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts new file mode 100644 index 00000000..bf2d6f90 --- /dev/null +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { DIM_OPACITY, IGlyphIdentifier, INVERTED_DEFAULT_COLOR } from './Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import { IColor } from '../../shared/Types'; +import BaseCharAtlas from './BaseCharAtlas'; +import { DEFAULT_ANSI_COLORS } from '../ColorManager'; +import { clearColor } from '../../shared/atlas/CharAtlasGenerator'; +import LRUMap from './LRUMap'; + +// In practice we're probably never going to exhaust a texture this large. For debugging purposes, +// however, it can be useful to set this to a really tiny value, to verify that LRU eviction works. +const TEXTURE_WIDTH = 1024; +const TEXTURE_HEIGHT = 1024; + +const TRANSPARENT_COLOR = { + css: 'rgba(0, 0, 0, 0)', + rgba: 0 +}; + +// Drawing to the cache is expensive: If we have to draw more than this number of glyphs to the +// cache in a single frame, give up on trying to cache anything else, and try to finish the current +// frame ASAP. +// +// This helps to limit the amount of damage a program can do when it would otherwise thrash the +// cache. +const FRAME_CACHE_DRAW_LIMIT = 100; + +interface IGlyphCacheValue { + index: number; + isEmpty: boolean; +} + +function getGlyphCacheKey(glyph: IGlyphIdentifier): string { + const styleFlags = (glyph.bold ? 0 : 4) + (glyph.dim ? 0 : 2) + (glyph.italic ? 0 : 1); + return `${glyph.bg}_${glyph.fg}_${styleFlags}${glyph.char}`; +} + +export default class DynamicCharAtlas extends BaseCharAtlas { + // An ordered map that we're using to keep track of where each glyph is in the atlas texture. + // It's ordered so that we can determine when to remove the old entries. + private _cacheMap: LRUMap; + + // The texture that the atlas is drawn to + private _cacheCanvas: HTMLCanvasElement; + private _cacheCtx: CanvasRenderingContext2D; + + // A temporary context that glyphs are drawn to before being transfered to the atlas. + private _tmpCtx: CanvasRenderingContext2D; + + // The number of characters stored in the atlas by width/height + private _width: number; + private _height: number; + + private _drawToCacheCount: number = 0; + + constructor(document: Document, private _config: ICharAtlasConfig) { + super(); + this._cacheCanvas = document.createElement('canvas'); + this._cacheCanvas.width = TEXTURE_WIDTH; + this._cacheCanvas.height = TEXTURE_HEIGHT; + // The canvas needs alpha because we use clearColor to convert the background color to alpha. + // It might also contain some characters with transparent backgrounds if allowTransparency is + // set. + this._cacheCtx = this._cacheCanvas.getContext('2d', {alpha: true}); + + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = this._config.scaledCharWidth; + tmpCanvas.height = this._config.scaledCharHeight; + this._tmpCtx = tmpCanvas.getContext('2d', {alpha: this._config.allowTransparency}); + + this._width = Math.floor(TEXTURE_WIDTH / this._config.scaledCharWidth); + this._height = Math.floor(TEXTURE_HEIGHT / this._config.scaledCharHeight); + const capacity = this._width * this._height; + this._cacheMap = new LRUMap(capacity); + this._cacheMap.prealloc(capacity); + + // This is useful for debugging + // document.body.appendChild(this._cacheCanvas); + } + + public beginFrame(): void { + this._drawToCacheCount = 0; + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + const glyphKey = getGlyphCacheKey(glyph); + const cacheValue = this._cacheMap.get(glyphKey); + if (cacheValue != null) { + this._drawFromCache(ctx, cacheValue, x, y); + return true; + } else if (this._canCache(glyph) && this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) { + let index; + if (this._cacheMap.size < this._cacheMap.capacity) { + index = this._cacheMap.size; + } else { + // we're out of space, so our call to set will delete this item + index = this._cacheMap.peek().index; + } + const cacheValue = this._drawToCache(glyph, index); + this._cacheMap.set(glyphKey, cacheValue); + this._drawFromCache(ctx, cacheValue, x, y); + return true; + } + return false; + } + + private _canCache(glyph: IGlyphIdentifier): boolean { + // Only cache ascii and extended characters for now, to be safe. In the future, we could do + // something more complicated to determine the expected width of a character. + // + // If we switch the renderer over to webgl at some point, we may be able to use blending modes + // to draw overlapping glyphs from the atlas: + // https://github.com/servo/webrender/issues/464#issuecomment-255632875 + // https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html + return glyph.code < 256; + } + + private _toCoordinates(index: number): [number, number] { + return [ + (index % this._width) * this._config.scaledCharWidth, + Math.floor(index / this._width) * this._config.scaledCharHeight + ]; + } + + private _drawFromCache( + ctx: CanvasRenderingContext2D, + cacheValue: IGlyphCacheValue, + x: number, + y: number + ): void { + // We don't actually need to do anything if this is whitespace. + if (cacheValue.isEmpty) { + return; + } + const [cacheX, cacheY] = this._toCoordinates(cacheValue.index); + ctx.drawImage( + this._cacheCanvas, + cacheX, + cacheY, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + x, + y, + this._config.scaledCharWidth, + this._config.scaledCharHeight + ); + } + + private _getColorFromAnsiIndex(idx: number): IColor { + if (idx < this._config.colors.ansi.length) { + return this._config.colors.ansi[idx]; + } + return DEFAULT_ANSI_COLORS[idx]; + } + + private _getBackgroundColor(glyph: IGlyphIdentifier): IColor { + if (this._config.allowTransparency) { + // The background color might have some transparency, so we need to render it as fully + // transparent in the atlas. Otherwise we'd end up drawing the transparent background twice + // around the anti-aliased edges of the glyph, and it would look too dark. + return TRANSPARENT_COLOR; + } else if (glyph.bg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.foreground; + } else if (glyph.bg < 256) { + return this._getColorFromAnsiIndex(glyph.bg); + } + return this._config.colors.background; + } + + private _getForegroundColor(glyph: IGlyphIdentifier): IColor { + if (glyph.fg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.background; + } else if (glyph.fg < 256) { + // 256 color support + return this._getColorFromAnsiIndex(glyph.fg); + } + return this._config.colors.foreground; + } + + // TODO: We do this (or something similar) in multiple places. We should split this off + // into a shared function. + private _drawToCache(glyph: IGlyphIdentifier, index: number): IGlyphCacheValue { + this._drawToCacheCount++; + + this._tmpCtx.save(); + + // draw the background + const backgroundColor = this._getBackgroundColor(glyph); + // Use a 'copy' composite operation to clear any existing glyph out of _tmpCtxWithAlpha, regardless of + // transparency in backgroundColor + this._tmpCtx.globalCompositeOperation = 'copy'; + this._tmpCtx.fillStyle = backgroundColor.css; + this._tmpCtx.fillRect(0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight); + this._tmpCtx.globalCompositeOperation = 'source-over'; + + // draw the foreground/glyph + const fontWeight = glyph.bold ? this._config.fontWeightBold : this._config.fontWeight; + const fontStyle = glyph.italic ? 'italic' : ''; + this._tmpCtx.font = + `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; + this._tmpCtx.textBaseline = 'top'; + + this._tmpCtx.fillStyle = this._getForegroundColor(glyph).css; + + // Apply alpha to dim the character + if (glyph.dim) { + this._tmpCtx.globalAlpha = DIM_OPACITY; + } + // Draw the character + this._tmpCtx.fillText(glyph.char, 0, 0); + this._tmpCtx.restore(); + + // clear the background from the character to avoid issues with drawing over the previous + // character if it extends past it's bounds + const imageData = this._tmpCtx.getImageData( + 0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight + ); + let isEmpty = false; + if (!this._config.allowTransparency) { + isEmpty = clearColor(imageData, backgroundColor); + } + + // copy the data from imageData to _cacheCanvas + const [x, y] = this._toCoordinates(index); + // putImageData doesn't do any blending, so it will overwrite any existing cache entry for us + this._cacheCtx.putImageData(imageData, x, y); + + return { + index, + isEmpty + }; + } +} diff --git a/src/renderer/atlas/LRUMap.test.ts b/src/renderer/atlas/LRUMap.test.ts new file mode 100644 index 00000000..ba01e410 --- /dev/null +++ b/src/renderer/atlas/LRUMap.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import LRUMap from './LRUMap'; + +describe('LRUMap', () => { + it('can be used to store and retrieve values', () => { + const map = new LRUMap(10); + map.set('keya', 'valuea'); + map.set('keyb', 'valueb'); + map.set('keyc', 'valuec'); + assert.strictEqual(map.get('keya'), 'valuea'); + assert.strictEqual(map.get('keyb'), 'valueb'); + assert.strictEqual(map.get('keyc'), 'valuec'); + }); + + it('maintains a size from insertions', () => { + const map = new LRUMap(10); + assert.strictEqual(map.size, 0); + map.set('a', 'value'); + assert.strictEqual(map.size, 1); + map.set('b', 'value'); + assert.strictEqual(map.size, 2); + }); + + it('deletes the oldest entry when the capacity is exceeded', () => { + const map = new LRUMap(4); + map.set('a', 'value'); + map.set('b', 'value'); + map.set('c', 'value'); + map.set('d', 'value'); + map.set('e', 'value'); + assert.isNull(map.get('a')); + assert.isNotNull(map.get('b')); + assert.isNotNull(map.get('c')); + assert.isNotNull(map.get('d')); + assert.isNotNull(map.get('e')); + assert.strictEqual(map.size, 4); + }); + + it('prevents a recently accessed entry from getting deleted', () => { + const map = new LRUMap(2); + map.set('a', 'value'); + map.set('b', 'value'); + map.get('a'); + // a would normally get deleted here, except that we called get() + map.set('c', 'value'); + assert.isNotNull(map.get('a')); + // b got deleted instead of a + assert.isNull(map.get('b')); + assert.isNotNull(map.get('c')); + }); + + it('supports mutation', () => { + const map = new LRUMap(10); + map.set('keya', 'oldvalue'); + map.set('keya', 'newvalue'); + // mutation doesn't change the size + assert.strictEqual(map.size, 1); + assert.strictEqual(map.get('keya'), 'newvalue'); + }); +}); diff --git a/src/renderer/atlas/LRUMap.ts b/src/renderer/atlas/LRUMap.ts new file mode 100644 index 00000000..4a03f2aa --- /dev/null +++ b/src/renderer/atlas/LRUMap.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +interface ILinkedListNode { + prev: ILinkedListNode; + next: ILinkedListNode; + key: string; + value: T; +} + +export default class LRUMap { + private _map = {}; + private _head: ILinkedListNode = null; + private _tail: ILinkedListNode = null; + private _nodePool: ILinkedListNode[] = []; + public size: number = 0; + + constructor(public capacity: number) { } + + private _unlinkNode(node: ILinkedListNode): void { + const prev = node.prev; + const next = node.next; + if (node === this._head) { + this._head = next; + } + if (node === this._tail) { + this._tail = prev; + } + if (prev !== null) { + prev.next = next; + } + if (next !== null) { + next.prev = prev; + } + } + + private _appendNode(node: ILinkedListNode): void { + const tail = this._tail; + if (tail !== null) { + tail.next = node; + } + node.prev = tail; + node.next = null; + this._tail = node; + if (this._head === null) { + this._head = node; + } + } + + /** + * Preallocate a bunch of linked-list nodes. Allocating these nodes ahead of time means that + * they're more likely to live next to each other in memory, which seems to improve performance. + * + * Each empty object only consumes about 60 bytes of memory, so this is pretty cheap, even for + * large maps. + */ + public prealloc(count: number): void { + const nodePool = this._nodePool; + for (let i = 0; i < count; i++) { + nodePool.push({ + prev: null, + next: null, + key: null, + value: null + }); + } + } + + public get(key: string): T | null { + // This is unsafe: We're assuming our keyspace doesn't overlap with Object.prototype. However, + // it's faster than calling hasOwnProperty, and in our case, it would never overlap. + const node = this._map[key]; + if (node !== undefined) { + this._unlinkNode(node); + this._appendNode(node); + return node.value; + } + return null; + } + + public peek(): T | null { + const head = this._head; + return head === null ? null : head.value; + } + + public set(key: string, value: T): void { + // This is unsafe: See note above. + let node = this._map[key]; + if (node !== undefined) { + // already exists, we just need to mutate it and move it to the end of the list + node = this._map[key]; + this._unlinkNode(node); + node.value = value; + } else if (this.size >= this.capacity) { + // we're out of space: recycle the head node, move it to the tail + node = this._head; + this._unlinkNode(node); + delete this._map[node.key]; + node.key = key; + node.value = value; + this._map[key] = node; + } else { + // make a new element + const nodePool = this._nodePool; + if (nodePool.length > 0) { + // use a preallocated node if we can + node = nodePool.pop(); + node.key = key; + node.value = value; + } else { + node = { + prev: null, + next: null, + key, + value + }; + } + this._map[key] = node; + this.size++; + } + this._appendNode(node); + } +} diff --git a/src/renderer/atlas/NoneCharAtlas.ts b/src/renderer/atlas/NoneCharAtlas.ts new file mode 100644 index 00000000..1cbc9eea --- /dev/null +++ b/src/renderer/atlas/NoneCharAtlas.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + * + * A dummy CharAtlas implementation that always fails to draw characters. + */ + +import { IGlyphIdentifier } from './Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import BaseCharAtlas from './BaseCharAtlas'; + +export default class NoneCharAtlas extends BaseCharAtlas { + constructor(document: Document, config: ICharAtlasConfig) { + super(); + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + return false; + } +} diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts new file mode 100644 index 00000000..b19074b8 --- /dev/null +++ b/src/renderer/atlas/StaticCharAtlas.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { DIM_OPACITY, IGlyphIdentifier } from './Types'; +import { CHAR_ATLAS_CELL_SPACING, ICharAtlasConfig } from '../../shared/atlas/Types'; +import { generateStaticCharAtlasTexture } from '../../shared/atlas/CharAtlasGenerator'; +import BaseCharAtlas from './BaseCharAtlas'; + +export default class StaticCharAtlas extends BaseCharAtlas { + private _texture: HTMLCanvasElement | ImageBitmap; + + constructor(private _document: Document, private _config: ICharAtlasConfig) { + super(); + } + + private _canvasFactory = (width: number, height: number) => { + const canvas = this._document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; + } + + public _doWarmUp(): void { + const result = generateStaticCharAtlasTexture(window, this._canvasFactory, this._config); + if (result instanceof HTMLCanvasElement) { + this._texture = result; + } else { + result.then(texture => { + this._texture = texture; + }); + } + } + + private _isCached(glyph: IGlyphIdentifier, colorIndex: number): boolean { + const isAscii = glyph.code < 256; + // A color is basic if it is one of the 4 bit ANSI colors. + const isBasicColor = glyph.fg < 16; + const isDefaultColor = glyph.fg >= 256; + const isDefaultBackground = glyph.bg >= 256; + return isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground && !glyph.italic; + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + // we're not warmed up yet + if (this._texture == null) { + return false; + } + + let colorIndex = 0; + if (glyph.fg < 256) { + colorIndex = 2 + glyph.fg + (glyph.bold ? 16 : 0); + } else { + // If default color and bold + if (glyph.bold) { + colorIndex = 1; + } + } + if (!this._isCached(glyph, colorIndex)) { + return false; + } + // ImageBitmap's draw about twice as fast as from a canvas + const charAtlasCellWidth = this._config.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; + const charAtlasCellHeight = this._config.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + + // Apply alpha to dim the character + if (glyph.dim) { + ctx.globalAlpha = DIM_OPACITY; + } + + ctx.drawImage( + this._texture, + glyph.code * charAtlasCellWidth, + colorIndex * charAtlasCellHeight, + charAtlasCellWidth, + this._config.scaledCharHeight, + x, + y, + charAtlasCellWidth, + this._config.scaledCharHeight + ); + + return true; + } +} diff --git a/src/renderer/atlas/Types.ts b/src/renderer/atlas/Types.ts index 34f01d39..46e4c9aa 100644 --- a/src/renderer/atlas/Types.ts +++ b/src/renderer/atlas/Types.ts @@ -5,3 +5,13 @@ export const INVERTED_DEFAULT_COLOR = -1; export const DIM_OPACITY = 0.5; + +export interface IGlyphIdentifier { + char: string; + code: number; + bg: number; + fg: number; + bold: boolean; + dim: boolean; + italic: boolean; +} diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts index fc83c7ce..276da78d 100644 --- a/src/shared/atlas/CharAtlasGenerator.ts +++ b/src/shared/atlas/CharAtlasGenerator.ts @@ -5,6 +5,7 @@ import { FontWeight } from 'xterm'; import { CHAR_ATLAS_CELL_SPACING, ICharAtlasConfig } from './Types'; +import { IColor } from '../Types'; import { isFirefox } from '../utils/Browser'; declare const Promise: any; @@ -20,14 +21,14 @@ export interface IOffscreenCanvas { * Generates a char atlas. * @param context The window or worker context. * @param canvasFactory A function to generate a canvas with a width or height. - * @param request The config for the new char atlas. + * @param config The config for the new char atlas. */ -export function generateCharAtlas(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { +export function generateStaticCharAtlasTexture(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { const cellWidth = config.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; const cellHeight = config.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; const canvas = canvasFactory( /*255 ascii chars*/255 * cellWidth, - (/*default+default bold*/2 + /*0-15*/16) * cellHeight + (/*default+default bold*/2 + /*0-15*/16 + /*0-15 bold*/16) * cellHeight ); const ctx = canvas.getContext('2d', {alpha: config.allowTransparency}); @@ -64,10 +65,6 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number // Colors 0-15 ctx.font = getFont(config.fontWeight, config); for (let colorIndex = 0; colorIndex < 16; colorIndex++) { - // colors 8-15 are bold - if (colorIndex === 8) { - ctx.font = getFont(config.fontWeightBold, config); - } const y = (colorIndex + 2) * cellHeight; // Draw ascii characters for (let i = 0; i < 256; i++) { @@ -80,6 +77,22 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number ctx.restore(); } } + + // Colors 0-15 bold + ctx.font = getFont(config.fontWeightBold, config); + for (let colorIndex = 0; colorIndex < 16; colorIndex++) { + const y = (colorIndex + 2 + 16) * cellHeight; + // Draw ascii characters + for (let i = 0; i < 256; i++) { + ctx.save(); + ctx.beginPath(); + ctx.rect(i * cellWidth, y, cellWidth, cellHeight); + ctx.clip(); + ctx.fillStyle = config.colors.ansi[colorIndex].css; + ctx.fillText(String.fromCharCode(i), i * cellWidth, y); + ctx.restore(); + } + } ctx.restore(); // Support is patchy for createImageBitmap at the moment, pass a canvas back @@ -91,34 +104,38 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number if (canvas instanceof HTMLCanvasElement) { // Just return the HTMLCanvas if it's a HTMLCanvasElement return canvas; - } else { - // Transfer to an ImageBitmap is this is an OffscreenCanvas - return new Promise(r => r(canvas.transferToImageBitmap())); } + // Transfer to an ImageBitmap is this is an OffscreenCanvas + return new Promise(r => r(canvas.transferToImageBitmap())); } const charAtlasImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // Remove the background color from the image so characters may overlap - const r = config.colors.background.rgba >>> 24; - const g = config.colors.background.rgba >>> 16 & 0xFF; - const b = config.colors.background.rgba >>> 8 & 0xFF; - clearColor(charAtlasImageData, r, g, b); + clearColor(charAtlasImageData, config.colors.background); return context.createImageBitmap(charAtlasImageData); } /** * Makes a partiicular rgb color in an ImageData completely transparent. + * @returns True if the result is "empty", meaning all pixels are fully transparent. */ -function clearColor(imageData: ImageData, r: number, g: number, b: number): void { +export function clearColor(imageData: ImageData, color: IColor): boolean { + let isEmpty = true; + const r = color.rgba >>> 24; + const g = color.rgba >>> 16 & 0xFF; + const b = color.rgba >>> 8 & 0xFF; for (let offset = 0; offset < imageData.data.length; offset += 4) { if (imageData.data[offset] === r && imageData.data[offset + 1] === g && imageData.data[offset + 2] === b) { imageData.data[offset + 3] = 0; + } else { + isEmpty = false; } } + return isEmpty; } function getFont(fontWeight: FontWeight, config: ICharAtlasConfig): string { diff --git a/src/shared/atlas/Types.ts b/src/shared/atlas/Types.ts index 4a66d554..25eaa716 100644 --- a/src/shared/atlas/Types.ts +++ b/src/shared/atlas/Types.ts @@ -9,6 +9,7 @@ import { IColorSet } from '../Types'; export const CHAR_ATLAS_CELL_SPACING = 1; export interface ICharAtlasConfig { + type: 'none' | 'static' | 'dynamic'; devicePixelRatio: number; fontSize: number; fontFamily: string; 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/src/xterm.css b/src/xterm.css index eec41a05..6e7d2f96 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -126,13 +126,17 @@ line-height: normal; } +.xterm { + cursor: text; +} + .xterm.enable-mouse-events { /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ cursor: default; } -.xterm:not(.enable-mouse-events) { - cursor: text; +.xterm.xterm-cursor-pointer { + cursor: pointer; } .xterm .xterm-accessibility, @@ -153,7 +157,3 @@ height: 1px; overflow: hidden; } - -.xterm-cursor-pointer { - cursor: pointer; -} diff --git a/tsconfig.json b/tsconfig.json index ffd9ab68..e56930e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,12 @@ "compilerOptions": { "module": "commonjs", "target": "es5", + "lib": [ + "DOM", + "ES5", + "ScriptHost", + "ES2015.Promise" + ], "rootDir": "src", "outDir": "lib", "sourceMap": true, diff --git a/tslint.json b/tslint.json index d42fda71..ac1b9c95 100644 --- a/tslint.json +++ b/tslint.json @@ -1,4 +1,7 @@ { + "rulesDirectory": [ + "tslint-consistent-codestyle" + ], "rules": { "array-type": [ true, @@ -86,6 +89,17 @@ "check-type", "check-type-operator", "check-preblock" + ], + + "naming-convention": [ + true, + {"type": "property", "modifiers": ["public", "static", "const"], "format": "UPPER_CASE"} + ], + "no-else-after-return": { + "options": "allow-else-if" + }, + "prefer-const-enum": [ + true ] } } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 5c9e95e5..a8724922 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -54,6 +54,11 @@ declare module 'xterm' { */ disableStdin?: boolean; + /** + * Whether to draw bold text in bright colors. The default is true. + */ + drawBoldTextInBrightColors?: boolean; + /** * Whether to enable the rendering of bold text. * @@ -61,6 +66,24 @@ declare module 'xterm' { */ enableBold?: boolean; + /** + * What character atlas implementation to use. The character atlas caches drawn characters, + * speeding up rendering significantly. However, it can introduce some minor rendering + * artifacts. + * + * - 'none': Don't use an atlas. + * - 'static': Generate an atlas when the terminal starts or is reconfigured. This atlas will + * only contain ASCII characters in 16 colors. + * - 'dynamic': Generate an atlas using a LRU cache as characters are requested. Limited to + * ASCII characters (for now), but supports 256 colors. For characters covered by the static + * cache, it's slightly slower in comparison, since there's more overhead involved in + * managing the cache. + * + * Currently defaults to 'static'. This option may be removed in the future. If it is, passed + * parameters will be ignored. + */ + experimentalCharAtlas?: 'none' | 'static' | 'dynamic'; + /** * The font size used to render text. */ @@ -251,7 +274,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. */ @@ -451,8 +474,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;