diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 35935355..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,11 +0,0 @@ - - -## Details -- Browser and browser version: -- OS version: -- xterm.js version: - -### Steps to reproduce - -1. -2. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..6a96665d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,17 @@ +--- +name: Bug report +about: Create a bug report +--- + + + + +## Details +- Browser and browser version: +- OS version: +- xterm.js version: + +### Steps to reproduce + +1. +2. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..979f35aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,7 @@ +--- +name: Feature request +about: Suggest a feature or enhancement +--- + + + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..10eee3d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,8 @@ +--- +name: Question +about: The issue tracker is not for questions. Please ask questions on https://stackoverflow.com/questions/tagged/xtermjs or https://gitter.im/sourcelair/xterm.js. +--- + +🛑 The issue tracker is not for questions 🛑 + +If you have a question, please ask it on https://stackoverflow.com/questions/tagged/xtermjs or https://gitter.im/sourcelair/xterm.js. diff --git a/.gitignore b/.gitignore index fd1bffce..25e93d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ fixtures/typings-test/*.js package-lock.json # Directories needed for code coverage -/coverage/ +coverage/ +.nyc_output/ # Keep bundled code out of Git dist/ diff --git a/.travis.yml b/.travis.yml index 1f3c9079..c212b19d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ env: matrix: - NPM_COMMAND=tsc - NPM_COMMAND=lint - - NPM_COMMAND=test + - NPM_COMMAND=test-coverage notifications: email: false script: npm run $NPM_COMMAND diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 064f0f5e..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Mocha Tests", - "cwd": "${workspaceRoot}", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/mocha", - "windows": { - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/mocha.cmd" - }, - "runtimeArgs": [ - "--colors", - "--recursive", - "${workspaceRoot}/lib" - ], - "sourceMaps": true, - "outFiles": [ "${workspaceRoot}/lib/**/*.js" ], - "internalConsoleOptions": "openOnSessionStart" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index f78b3f81..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "search.exclude": { - "**/node_modules": true, - "**/dist": true, - "**/build": true, - "**/lib": true - } -} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..e8ba414b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting [@Tyriar](https://twitter.com/Tyriar) or [@pariskasid](https://twitter.com/pariskasid) on Twitter. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/README.md b/README.md index 39c80a46..1122c59c 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/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) +[![Travis CI build status](https://api.travis-ci.org/xtermjs/xterm.js.svg)](https://travis-ci.org/xtermjs/xterm.js) [![VSTS Build status](https://xtermjs.visualstudio.com/_apis/public/build/definitions/3e323cf7-5760-460d-af64-ee5675baf366/1/badge)](https://xtermjs.visualstudio.com/xterm.js/_build/index?definitionId=1) [![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. @@ -39,7 +39,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
@@ -150,8 +150,11 @@ computational environment for Jupyter, supporting interactive data science and s - [**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. - [**GoTTY**](https://github.com/yudai/gotty): A simple command line tool that shares your terminal as a web application based on xterm.js. +- [**genact**](https://github.com/svenstaro/genact): A nonsense activity generator. +- [**cPanel & WHM**](https://cpanel.com): The hosting platform of choice. +- [**Nutanix**](https://github.com/nutanix): Nutanix Enterprise Cloud uses xterm in the webssh functionality within Nutanix Calm, and is also looking to move our old noserial (termjs) functionality to xterm.js -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. +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. Note: Please add any new contributions to the end of the list only. ## Demo @@ -224,6 +227,16 @@ Then open http://127.0.0.1:3000 in a web browser to access the demo. *Note: Do not use ConEmu, as it seems to break the demo for some reason.* +## Testing + +Tests are run using the following npm scripts: + +- `npm test`: This will run both unit tests and the linter +- `npm run test-suite `: This will run all tests within a particular file, <file> is the test file name excluding the extension (eg. "Linkifier.test") +- `npm run test-debug`: This will run unit tests with `--inspect-brk`, this can then be automatically debugged using [VS Code auto attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach-feature) or manually attached to by a debugger +- `npm run test-coverage`: This will run all unit tests and produce a coverage report +- `npm run lint`: This will run the linter only + ## Releases Xterm.js follows a monthly release cycle roughly. @@ -238,12 +251,12 @@ To contribute either code, documentation or issues to xterm.js please read the [ ### Code structure -`src/` is roughly split up into areas of functionality such as `renderer/` that handles all rendering and `utils/` which provides general utility functions. The `shared/` folder contains code that can be used from either the main thread or a web worker thread, all code inside a `shared/` folder should only ever import other code from a `shared/` folder to minimize the amount of code run what launching a web worker. +`src/` is roughly split up into areas of functionality such as `renderer/` that handles all rendering and `utils/` which provides general utility functions. The `shared/` folder contains code that can be used from either the main thread or a web worker thread, all code inside a `shared/` folder should only ever import other code from a `shared/` folder to minimize the amount of code ran when launching a web worker. ## License Agreement If you contribute code to this project, you are implicitly allowing your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work. -Copyright (c) 2017-2018, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License) -Copyright (c) 2014-2017, SourceLair, Private Company ([www.sourcelair.com](https://www.sourcelair.com/home)) (MIT License) +Copyright (c) 2017-2018, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License)
+Copyright (c) 2014-2017, SourceLair, Private Company ([www.sourcelair.com](https://www.sourcelair.com/home)) (MIT License)
Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) diff --git a/demo/index.html b/demo/index.html index 168f56e4..fff77d09 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,95 +1,38 @@ - - xterm.js demo - - - - - - - -

xterm.js: A terminal for the web

-
-
-

Actions

-

- - -

-
-
-

Options

-

- -

-

- -

-

- -

-

- -

-

- -

-

- -

-

- -

-

- -

-
-

Size

-
-
- - -
-
- - -
-
- - -
-
-
-
-

Accessibility

-

- -

-
-

Attention: The demo is a barebones implementation and is designed for the development and evaluation of xterm.js only. Exposing the demo to the public as is would introduce security risks for the host.

- - + + xterm.js demo + + + + + + + +

xterm.js: A terminal for the web

+
+
+

Actions

+

+ + +

+
+
+

Options

+

These options can be set in the Terminal constructor or using the Terminal.setOption function.

+
+
+
+

Style

+
+ + +
+
+
+

Attention: The demo is a barebones implementation and is designed for the development and evaluation of xterm.js only. Exposing the demo to the public as is would introduce security risks for the host.

+ + + diff --git a/demo/main.js b/demo/main.js index 682fc5a0..a9b11655 100644 --- a/demo/main.js +++ b/demo/main.js @@ -26,97 +26,38 @@ var terminalContainer = document.getElementById('terminal-container'), findNext: document.querySelector('#find-next'), findPrevious: document.querySelector('#find-previous') }, - optionElements = { - cursorBlink: document.querySelector('#option-cursor-blink'), - 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') - }, - colsElement = document.getElementById('cols'), - rowsElement = document.getElementById('rows'), paddingElement = document.getElementById('padding'); -function setTerminalSize() { - var cols = parseInt(colsElement.value, 10); - var rows = parseInt(rowsElement.value, 10); - var width = (cols * term.renderer.dimensions.actualCellWidth + term.viewport.scrollBarWidth).toString() + 'px'; - var height = (rows * term.renderer.dimensions.actualCellHeight).toString() + 'px'; - terminalContainer.style.width = width; - terminalContainer.style.height = height; - term.fit(); -} - function setPadding() { term.element.style.padding = parseInt(paddingElement.value, 10).toString() + 'px'; term.fit(); } -colsElement.addEventListener('change', setTerminalSize); -rowsElement.addEventListener('change', setTerminalSize); -paddingElement.addEventListener('change', setPadding); - -actionElements.findNext.addEventListener('keypress', function (e) { - if (e.key === "Enter") { - e.preventDefault(); - term.findNext(actionElements.findNext.value); - } -}); -actionElements.findPrevious.addEventListener('keypress', function (e) { - if (e.key === "Enter") { - e.preventDefault(); - term.findPrevious(actionElements.findPrevious.value); - } -}); - -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.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); -}); - createTerminal(); +const disposeRecreateButtonHandler = () => { + // If the terminal exists dispose of it, otherwise recreate it + if (term) { + term.dispose(); + term = null; + window.term = null; + socket = null; + document.getElementById('dispose').innerHTML = 'Recreate Terminal'; + } + else { + createTerminal(); + document.getElementById('dispose').innerHTML = 'Dispose terminal'; + } +}; + +document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); + function createTerminal() { // Clean terminal while (terminalContainer.children.length) { terminalContainer.removeChild(terminalContainer.children[0]); } - term = new Terminal({ - macOptionIsMeta: optionElements.macOptionIsMeta.enabled, - cursorBlink: optionElements.cursorBlink.checked, - scrollback: parseInt(optionElements.scrollback.value, 10), - tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10), - screenReaderMode: optionElements.screenReaderMode.checked - }); + term = new Terminal({}); window.term = term; // Expose `term` to window for debugging purposes term.on('resize', function (size) { if (!pid) { @@ -137,14 +78,30 @@ function createTerminal() { term.fit(); term.focus(); + addDomListener(paddingElement, 'change', setPadding); + + addDomListener(actionElements.findNext, 'keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findNext(actionElements.findNext.value); + } + }); + addDomListener(actionElements.findPrevious, 'keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findPrevious(actionElements.findPrevious.value); + } + }); + // fit is called within a setTimeout, cols and rows need this. setTimeout(function () { - colsElement.value = term.cols; - rowsElement.value = term.rows; + initOptions(term); + document.getElementById(`opt-cols`).value = term.cols; + document.getElementById(`opt-rows`).value = term.rows; paddingElement.value = 0; // Set terminal size again to set the specific dimensions on the demo - setTerminalSize(); + updateTerminalSize(); fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then(function (res) { @@ -184,7 +141,7 @@ function runFakeTerminal() { term.writeln(''); term.prompt(); - term.on('key', function (key, ev) { + term._core.register(term.addDisposableListener('key', function (key, ev) { var printable = ( !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey ); @@ -199,9 +156,116 @@ function runFakeTerminal() { } else if (printable) { term.write(key); } + })); + + term._core.register(term.addDisposableListener('paste', function (data, ev) { + term.write(data); + })); +} + +function initOptions(term) { + var blacklistedOptions = [ + // Internal only options + 'cancelEvents', + 'convertEol', + 'debug', + 'handler', + 'screenKeys', + 'termName', + 'useFlowControl', + // Complex option + 'theme', + // Only in constructor + 'rendererType' + ]; + var stringOptions = { + bellSound: null, + bellStyle: ['none', 'sound'], + cursorStyle: ['block', 'underline', 'bar'], + experimentalCharAtlas: ['none', 'static', 'dynamic'], + fontFamily: null, + fontWeight: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], + fontWeightBold: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'] + }; + var options = Object.keys(term._core.options); + var booleanOptions = []; + var numberOptions = []; + options.filter(o => blacklistedOptions.indexOf(o) === -1).forEach(o => { + switch (typeof term.getOption(o)) { + case 'boolean': + booleanOptions.push(o); + break; + case 'number': + numberOptions.push(o); + break; + default: + if (Object.keys(stringOptions).indexOf(o) === -1) { + console.warn(`Unrecognized option: "${o}"`); + } + } }); - term.on('paste', function (data, ev) { - term.write(data); + var html = ''; + html += '
'; + booleanOptions.forEach(o => { + html += `
`; + }); + html += '
'; + numberOptions.forEach(o => { + html += `
`; + }); + html += '
'; + Object.keys(stringOptions).forEach(o => { + if (stringOptions[o]) { + html += `
`; + } else { + html += `
` + } + }); + html += '
'; + + var container = document.getElementById('options-container'); + container.innerHTML = html; + + // Attach listeners + booleanOptions.forEach(o => { + var input = document.getElementById(`opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.checked); + term.setOption(o, input.checked); + }); + }); + numberOptions.forEach(o => { + var input = document.getElementById(`opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.value); + if (o === 'cols' || o === 'rows') { + updateTerminalSize(); + } else { + term.setOption(o, parseInt(input.value, 10)); + } + }); + }); + Object.keys(stringOptions).forEach(o => { + var input = document.getElementById(`opt-${o}`); + addDomListener(input, 'change', () => { + console.log('change', o, input.value); + term.setOption(o, input.value); + }); }); } + +function addDomListener(element, type, handler) { + element.addEventListener(type, handler); + term._core.register({ dispose: () => element.removeEventListener(type, handler) }); +} + +function updateTerminalSize() { + var cols = parseInt(document.getElementById(`opt-cols`).value, 10); + var rows = parseInt(document.getElementById(`opt-rows`).value, 10); + var width = (cols * term._core.renderer.dimensions.actualCellWidth + term._core.viewport.scrollBarWidth).toString() + 'px'; + var height = (rows * term._core.renderer.dimensions.actualCellHeight).toString() + 'px'; + terminalContainer.style.width = width; + terminalContainer.style.height = height; + term.fit(); +} diff --git a/demo/style.css b/demo/style.css index 1ab5f61c..b061dfcb 100644 --- a/demo/style.css +++ b/demo/style.css @@ -14,3 +14,19 @@ h1 { margin: 0 auto; padding: 2px; } + +p { + font-size: 0.9em; + font-style: italic +} + +#option-container { + display: flex; + justify-content: center; +} + +.option-group { + display: inline-block; + padding-left: 20px; + vertical-align: top; +} diff --git a/gulpfile.js b/gulpfile.js index af4fdce0..7026ec43 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,11 +4,9 @@ const browserify = require('browserify'); const buffer = require('vinyl-buffer'); -const coveralls = require('gulp-coveralls'); const fs = require('fs-extra'); const gulp = require('gulp'); const path = require('path'); -const istanbul = require('gulp-istanbul'); const merge = require('merge-stream'); const mocha = require('gulp-mocha'); const sorcery = require('sorcery'); @@ -20,69 +18,37 @@ const webpack = require('webpack-stream'); const buildDir = process.env.BUILD_DIR || 'build'; const tsProject = ts.createProject('tsconfig.json'); -const srcDir = tsProject.config.compilerOptions.rootDir; +let srcDir = tsProject.config.compilerOptions.rootDir; let outDir = tsProject.config.compilerOptions.outDir; const addons = fs.readdirSync(`${__dirname}/src/addons`); +const TEST_PATHS = [ + `${outDir}/*test.js`, + `${outDir}/**/*test.js`, + `${outDir}/*integration.js`, + `${outDir}/**/*integration.js` +]; + // Under some environments like TravisCI, this comes out at absolute which can // break the build. This ensures that the outDir is absolute. if (path.normalize(outDir).indexOf(__dirname) !== 0) { outDir = `${__dirname}/${path.normalize(outDir)}`; } -/** - * Compile TypeScript sources to JavaScript files and create a source map file for each TypeScript - * file compiled. - */ -gulp.task('tsc', function () { - // Remove the ${outDir}/ directory to prevent confusion if files were deleted in ${srcDir}/ - fs.emptyDirSync(`${outDir}`); +gulp.task('css', function() { + return gulp.src(`${srcDir}/**/*.css`).pipe(gulp.dest(outDir)); +}); - // Build all TypeScript files (including tests) to ${outDir}/, based on the configuration defined in - // `tsconfig.json`. - let tsResult = tsProject.src().pipe(sourcemaps.init()).pipe(tsProject()); - let tsc = merge( - tsResult.js.pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})).pipe(gulp.dest(outDir)), - tsResult.dts.pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})).pipe(gulp.dest(outDir)) - ); - - let addonStreams = addons.map(function(addon) { - fs.emptyDirSync(`${outDir}/addons/${addon}`); - - let tsProjectAddon = ts.createProject(`./src/addons/${addon}/tsconfig.json`); - let tsResultAddon = tsProjectAddon.src().pipe(sourcemaps.init()).pipe(tsProjectAddon()); - let tscAddon = merge( - tsResultAddon.js - .pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})) - .pipe(gulp.dest(`${outDir}/addons/${addon}`)), - tsResultAddon.dts - .pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})) - .pipe(gulp.dest(`${outDir}/addons/${addon}`)) - ) - - return tscAddon; - }); - - // Copy all addons from ${srcDir}/ to ${outDir}/ - let copyAddons = gulp.src([ - `${srcDir}/addons/**/**` - ]).pipe(gulp.dest(`${outDir}/addons`)); - - // Copy stylesheets from ${srcDir}/ to ${outDir}/ - let copyStylesheets = gulp.src(`${srcDir}/**/*.css`).pipe(gulp.dest(outDir)); - - // Join all streams into a single array - let streams = [tsc].concat(addonStreams).concat([copyAddons, copyStylesheets]); - - return merge.apply(this, streams); +gulp.task('watch-css', function() { + return gulp.watch(`${srcDir}/**/*.css`, ['css']); }); /** * Bundle JavaScript files produced by the `tsc` task, into a single file named `xterm.js` with * Browserify. */ -gulp.task('browserify', ['tsc'], function() { +gulp.task('browserify', function() { // Ensure that the build directory exists fs.ensureDirSync(buildDir); @@ -108,7 +74,7 @@ gulp.task('browserify', ['tsc'], function() { return merge(bundleStream, copyStylesheets); }); -gulp.task('browserify-addons', ['tsc'], function() { +gulp.task('browserify-addons', function() { const bundles = addons.map((addon) => { const addonOptions = { basedir: `${buildDir}/addons/${addon}`, @@ -134,37 +100,22 @@ gulp.task('browserify-addons', ['tsc'], function() { return merge(...bundles); }); -gulp.task('instrument-test', function () { - return gulp.src([`${outDir}/**/*.js`]) - // Covering files - .pipe(istanbul()) - // Force `require` to return covered files - .pipe(istanbul.hookRequire()); -}); - -gulp.task('mocha', ['instrument-test'], function () { - return gulp.src([ - `${outDir}/*test.js`, - `${outDir}/**/*test.js`, - `${outDir}/*integration.js`, - `${outDir}/**/*integration.js` - ], {read: false}) +gulp.task('mocha', function () { + return gulp.src(TEST_PATHS, {read: false}) .pipe(mocha()) - .once('error', () => process.exit(1)) - .pipe(istanbul.writeReports()); + .once('error', () => process.exit(1)); }); /** - * Run single test file by file name(without file extension). Example of the command: - * gulp mocha-test --test InputHandler.test + * Run single test suite (file) by file name (without file extension). Example of the command: + * gulp mocha-suite --test InputHandler.test */ -gulp.task('mocha-test', ['instrument-test'], function () { +gulp.task('mocha-suite', [], function () { let testName = util.env.test; util.log("Run test by Name: " + testName); return gulp.src([`${outDir}/${testName}.js`, `${outDir}/**/${testName}.js`], {read: false}) .pipe(mocha()) - .once('error', () => process.exit(1)) - .pipe(istanbul.writeReports()); + .once('error', () => process.exit(1)); }); /** @@ -192,16 +143,9 @@ gulp.task('webpack', ['build'], function() { .pipe(gulp.dest('demo/dist/')); }); -gulp.task('watch', ['webpack'], () => { - gulp.watch(['./src/*', './src/**/*'], ['webpack']); -}); -/** - * Submit coverage results to coveralls.io - */ -gulp.task('coveralls', function () { - gulp.src('coverage/**/lcov.info') - .pipe(coveralls()); +gulp.task('watch-demo', ['webpack'], () => { + gulp.watch(['./demo/*', './lib/**/*'], ['webpack']); }); gulp.task('build', ['sorcery', 'sorcery-addons']); diff --git a/package.json b/package.json index 44cc2e88..2887d56a 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,20 @@ "name": "xterm", "description": "Full xterm terminal, in your browser", "version": "3.4.0", - "main": "lib/Terminal.js", + "main": "lib/public/Terminal.js", "types": "typings/xterm.d.ts", "repository": "https://github.com/xtermjs/xterm.js", "license": "MIT", "devDependencies": { "@types/chai": "^3.4.34", - "@types/jsdom": "^11.0.1", + "@types/glob": "^5.0.35", + "@types/jsdom": "11.0.1", "@types/mocha": "^2.2.33", "@types/node": "6.0.108", - "@types/text-encoding": "0.0.32", "browserify": "^13.3.0", "chai": "3.5.0", + "concurrently": "^3.5.1", + "coveralls": "^3.0.1", "express": "4.13.4", "express-ws": "2.0.0-rc.1", "fs-extra": "^1.0.0", @@ -21,22 +23,21 @@ "gulp": "3.9.1", "gulp-cli": "^1.2.2", "gulp-concat": "^2.6.1", - "gulp-coveralls": "^0.1.4", - "gulp-istanbul": "^1.1.1", "gulp-mocha": "^3.0.1", "gulp-sourcemaps": "1.9.1", "gulp-typescript": "^3.1.3", "gulp-util": "3.0.8", "jsdoc": "3.4.3", - "jsdom": "^11.1.0", + "jsdom": "^11.11.0", "merge-stream": "^1.0.1", - "node-pty": "^0.7.2", + "node-pty": "0.7.6", "nodemon": "1.10.2", "npm-run-all": "^4.1.2", + "nyc": "^11.8.0", "sorcery": "^0.10.0", "tslint": "^5.9.1", "tslint-consistent-codestyle": "^1.13.0", - "typescript": "~2.7.1", + "typescript": "2.8.3", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", "webpack": "^3.10.0", @@ -44,17 +45,21 @@ "zmodem.js": "^0.1.5" }, "scripts": { - "start": "node demo/app", + "start": "concurrently --kill-others-on-fail --names \"demo,server\" \"gulp watch-demo\" \"node demo/app\"", "start-zmodem": "node demo/zmodem/app", "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", + "test-coverage": "nyc -x gulpfile.js -x '**/*test*' npm run mocha", "mocha": "gulp test", "build:docs": "jsdoc -c jsdoc.json", "tsc": "tsc", + "prebuild": "concurrently --kill-others-on-fail --names \"lib,attach,fit,fullscreen,search,terminado,webLinks,winptyCompat,zmodem,css\" \"tsc\" \"tsc -p ./src/addons/attach\" \"tsc -p ./src/addons/fit\" \"tsc -p ./src/addons/fullscreen\" \"tsc -p ./src/addons/search\" \"tsc -p ./src/addons/terminado\" \"tsc -p ./src/addons/webLinks\" \"tsc -p ./src/addons/winptyCompat\" \"tsc -p ./src/addons/zmodem\" \"gulp css\"", "build": "gulp build", "prepublish": "npm run build", - "coveralls": "gulp coveralls", + "coveralls": "nyc report --reporter=text-lcov | coveralls", "webpack": "gulp webpack", - "watch": "gulp watch" + "watch": "concurrently --kill-others-on-fail --names \"lib,attach,fit,fullscreen,search,terminado,webLinks,winptyCompat,zmodem,css\" \"tsc -w\" \"tsc -w -p ./src/addons/attach\" \"tsc -w -p ./src/addons/fit\" \"tsc -w -p ./src/addons/fullscreen\" \"tsc -w -p ./src/addons/search\" \"tsc -w -p ./src/addons/terminado\" \"tsc -w -p ./src/addons/webLinks\" \"tsc -w -p ./src/addons/winptyCompat\" \"tsc -w -p ./src/addons/zmodem\" \"gulp watch-css\"" } } diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index a7e205e3..0975e0b6 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -6,9 +6,9 @@ import * as Strings from './Strings'; import { ITerminal, IBuffer } from './Types'; import { isMac } from './shared/utils/Browser'; -import { RenderDebouncer } from './utils/RenderDebouncer'; -import { addDisposableListener } from './utils/Dom'; -import { IDisposable } from 'xterm'; +import { RenderDebouncer } from './ui/RenderDebouncer'; +import { addDisposableDomListener } from './ui/Lifecycle'; +import { Disposable } from './common/Lifecycle'; const MAX_ROWS_TO_READ = 20; @@ -17,7 +17,7 @@ const enum BoundaryPosition { BOTTOM } -export class AccessibilityManager implements IDisposable { +export class AccessibilityManager extends Disposable { private _accessibilityTreeRoot: HTMLElement; private _rowContainer: HTMLElement; private _rowElements: HTMLElement[]; @@ -29,8 +29,6 @@ export class AccessibilityManager implements IDisposable { private _topBoundaryFocusListener: (e: FocusEvent) => void; private _bottomBoundaryFocusListener: (e: FocusEvent) => void; - private _disposables: IDisposable[] = []; - /** * This queue has a character pushed to it for keys that are pressed, if the * next character added to the terminal is equal to the key char then it is @@ -43,6 +41,7 @@ export class AccessibilityManager implements IDisposable { private _charsToConsume: string[] = []; constructor(private _terminal: ITerminal) { + super(); this._accessibilityTreeRoot = document.createElement('div'); this._accessibilityTreeRoot.classList.add('xterm-accessibility'); @@ -72,29 +71,28 @@ export class AccessibilityManager implements IDisposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot); - this._disposables.push(this._renderRowsDebouncer); - this._disposables.push(this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows))); - this._disposables.push(this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end))); - this._disposables.push(this._terminal.addDisposableListener('scroll', data => this._refreshRows())); + this.register(this._renderRowsDebouncer); + this.register(this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows))); + this.register(this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end))); + this.register(this._terminal.addDisposableListener('scroll', data => this._refreshRows())); // Line feed is an issue as the prompt won't be read out after a command is run - this._disposables.push(this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char))); - this._disposables.push(this._terminal.addDisposableListener('linefeed', () => this._onChar('\n'))); - this._disposables.push(this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount))); - this._disposables.push(this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar))); - this._disposables.push(this._terminal.addDisposableListener('blur', () => this._clearLiveRegion())); + this.register(this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char))); + this.register(this._terminal.addDisposableListener('linefeed', () => this._onChar('\n'))); + this.register(this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount))); + this.register(this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar))); + this.register(this._terminal.addDisposableListener('blur', () => this._clearLiveRegion())); // TODO: Maybe renderer should fire an event on terminal when the characters change and that // should be listened to instead? That would mean that the order of events are always // guarenteed - this._disposables.push(this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions())); - this._disposables.push(this._terminal.renderer.addDisposableListener('resize', () => this._refreshRowsDimensions())); + this.register(this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions())); + this.register(this._terminal.renderer.addDisposableListener('resize', () => this._refreshRowsDimensions())); // This shouldn't be needed on modern browsers but is present in case the // media query that drives the dprchange event isn't supported - this._disposables.push(addDisposableListener(window, 'resize', () => this._refreshRowsDimensions())); + this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions())); } public dispose(): void { - this._disposables.forEach(d => d.dispose()); - this._disposables.length = 0; + super.dispose(); this._terminal.element.removeChild(this._accessibilityTreeRoot); this._rowElements.length = 0; } @@ -179,7 +177,7 @@ export class AccessibilityManager implements IDisposable { this._refreshRowsDimensions(); } - public _createAccessibilityTreeNode(): HTMLElement { + private _createAccessibilityTreeNode(): HTMLElement { const element = document.createElement('div'); element.setAttribute('role', 'listitem'); element.tabIndex = -1; diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 44687f0e..a51e5456 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { ITerminal } from './Types'; import { Buffer } from './Buffer'; -import { CircularList } from './utils/CircularList'; +import { CircularList } from './common/CircularList'; import { MockTerminal } from './utils/TestUtils.test'; const INIT_COLS = 80; @@ -48,6 +48,63 @@ describe('Buffer', () => { }); }); + describe('getWrappedRangeForLine', () => { + describe('non-wrapped', () => { + it('should return a single row for the first row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 0 }); + }); + it('should return a single row for a middle row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 12 }); + }); + it('should return a single row for the last row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 23, last: 23 }); + }); + }); + describe('wrapped', () => { + it('should return a range for the first row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 1 }); + }); + it('should return a range for a middle row wrapping upwards', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(12)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 11, last: 12 }); + }); + it('should return a range for a middle row wrapping downwards', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(13)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 13 }); + }); + it('should return a range for a middle row wrapping both ways', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(11)).isWrapped = true; + ( buffer.lines.get(12)).isWrapped = true; + ( buffer.lines.get(13)).isWrapped = true; + ( buffer.lines.get(14)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 10, last: 14 }); + }); + it('should return a range for the last row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(23)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 22, last: 23 }); + }); + it('should return a range for a row that wraps upward to first row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(1), { first: 0, last: 1 }); + }); + it('should return a range for a row that wraps downward to last row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(buffer.lines.length - 1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 2), { first: 22, last: 23 }); + }); + }); + }); + describe('resize', () => { describe('column size is reduced', () => { it('should not trim the data in the buffer', () => { @@ -212,4 +269,81 @@ describe('Buffer', () => { assert.equal(buffer.markers.length, 0); }); }); + + describe ('translateBufferLineToString', () => { + it('should handle selecting a section of ascii text', () => { + buffer.lines.set(0, [ + [ null, 'a', 1, 'a'.charCodeAt(0)], + [ null, 'b', 1, 'b'.charCodeAt(0)], + [ null, 'c', 1, 'c'.charCodeAt(0)], + [ null, 'd', 1, 'd'.charCodeAt(0)] + ]); + + const str = buffer.translateBufferLineToString(0, true, 0, 2); + assert.equal(str, 'ab'); + }); + + it('should handle a cut-off double width character by including it', () => { + buffer.lines.set(0, [ + [ null, '語', 2, 35486 ], + [ null, '', 0, null], + [ null, 'a', 1, 'a'.charCodeAt(0)] + ]); + + const str1 = buffer.translateBufferLineToString(0, true, 0, 1); + assert.equal(str1, '語'); + }); + + it('should handle a zero width character in the middle of the string by not including it', () => { + buffer.lines.set(0, [ + [ null, '語', 2, '語'.charCodeAt(0) ], + [ null, '', 0, null], + [ null, 'a', 1, 'a'.charCodeAt(0)] + ]); + + const str0 = buffer.translateBufferLineToString(0, true, 0, 1); + assert.equal(str0, '語'); + + const str1 = buffer.translateBufferLineToString(0, true, 0, 2); + assert.equal(str1, '語'); + + const str2 = buffer.translateBufferLineToString(0, true, 0, 3); + assert.equal(str2, '語a'); + }); + + it('should handle single width emojis', () => { + buffer.lines.set(0, [ + [ null, '😁', 1, '😁'.charCodeAt(0) ], + [ null, 'a', 1, 'a'.charCodeAt(0)] + ]); + + const str1 = buffer.translateBufferLineToString(0, true, 0, 1); + assert.equal(str1, '😁'); + + const str2 = buffer.translateBufferLineToString(0, true, 0, 2); + assert.equal(str2, '😁a'); + }); + + it('should handle double width emojis', () => { + buffer.lines.set(0, [ + [ null, '😁', 2, '😁'.charCodeAt(0) ], + [ null, '', 0, null] + ]); + + const str1 = buffer.translateBufferLineToString(0, true, 0, 1); + assert.equal(str1, '😁'); + + const str2 = buffer.translateBufferLineToString(0, true, 0, 2); + assert.equal(str2, '😁'); + + buffer.lines.set(0, [ + [ null, '😁', 2, '😁'.charCodeAt(0) ], + [ null, '', 0, null], + [ null, 'a', 1, 'a'.charCodeAt(0)] + ]); + + const str3 = buffer.translateBufferLineToString(0, true, 0, 3); + assert.equal(str3, '😁a'); + }); + }); }); diff --git a/src/Buffer.ts b/src/Buffer.ts index 1ea303b2..5183009f 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -3,11 +3,12 @@ * @license MIT */ -import { CircularList } from './utils/CircularList'; +import { CircularList } from './common/CircularList'; import { LineData, CharData, ITerminal, IBuffer } from './Types'; import { EventEmitter } from './EventEmitter'; -import { IDisposable, IMarker } from 'xterm'; +import { IMarker } from 'xterm'; +export const DEFAULT_ATTR = (0 << 18) | (257 << 9) | (256 << 0); export const CHAR_DATA_ATTR_INDEX = 0; export const CHAR_DATA_CHAR_INDEX = 1; export const CHAR_DATA_WIDTH_INDEX = 2; @@ -116,7 +117,7 @@ export class Buffer implements IBuffer { 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? + const ch: CharData = [DEFAULT_ATTR, ' ', 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); @@ -226,7 +227,7 @@ export class Buffer implements IBuffer { if (startCol >= i) { startIndex--; } - if (endCol >= i) { + if (endCol > i) { endIndex--; } } else { @@ -259,6 +260,20 @@ export class Buffer implements IBuffer { return lineString.substring(startIndex, endIndex); } + public getWrappedRangeForLine(y: number): { first: number, last: number } { + let first = y; + let last = y; + // Scan upwards for wrapped lines + while (first > 0 && (this.lines.get(first)).isWrapped) { + first--; + } + // Scan downwards for wrapped lines + while (last + 1 < this.lines.length && (this.lines.get(last + 1)).isWrapped) { + last++; + } + return { first, last }; + } + /** * Setup the tab stops. * @param i The index to start setting up tab stops from. @@ -305,14 +320,14 @@ 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.register(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) { marker.dispose(); } })); - marker.on('dispose', () => this._removeMarker(marker)); + marker.register(marker.addDisposableListener('dispose', () => this._removeMarker(marker))); return marker; } @@ -323,11 +338,10 @@ export class Buffer implements IBuffer { } export class Marker extends EventEmitter implements IMarker { - private static NEXT_ID = 1; + private static _nextId = 1; - private _id: number = Marker.NEXT_ID++; + private _id: number = Marker._nextId++; public isDisposed: boolean = false; - public disposables: IDisposable[] = []; public get id(): number { return this._id; } @@ -342,8 +356,8 @@ export class Marker extends EventEmitter implements IMarker { return; } this.isDisposed = true; - this.disposables.forEach(d => d.dispose()); - this.disposables.length = 0; + // Emit before super.dispose such that dispose listeners get a change to react this.emit('dispose'); + super.dispose(); } } diff --git a/src/CharWidth.ts b/src/CharWidth.ts index 2165d9ba..6e4aa37f 100644 --- a/src/CharWidth.ts +++ b/src/CharWidth.ts @@ -121,7 +121,7 @@ export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: nu } const control = opts.control | 0; let table: number[] | Uint32Array = null; - function init_table(): number[] | Uint32Array { + function initTable(): number[] | Uint32Array { // lookup table for BMP const CODEPOINTS = 65536; // BMP holds 65536 codepoints const BITWIDTH = 2; // a codepoint can have a width of 0, 1 or 2 @@ -161,7 +161,7 @@ export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: nu if (num < 127) { return 1; } - const t = table || init_table(); + const t = table || initTable(); if (num < 65536) { return t[num >> 4] >> ((num & 15) << 1) & 3; } diff --git a/src/CompositionHelper.test.ts b/src/CompositionHelper.test.ts index 02231723..053a0c29 100644 --- a/src/CompositionHelper.test.ts +++ b/src/CompositionHelper.test.ts @@ -5,13 +5,14 @@ import { assert } from 'chai'; import { CompositionHelper } from './CompositionHelper'; +import { ITerminal } from './Types'; describe('CompositionHelper', () => { - let terminal; - let compositionHelper; - let compositionView; - let textarea; - let handledText; + let terminal: ITerminal; + let compositionHelper: CompositionHelper; + let compositionView: HTMLElement; + let textarea: HTMLTextAreaElement; + let handledText: string; beforeEach(() => { compositionView = { @@ -27,14 +28,14 @@ describe('CompositionHelper', () => { top: 0 }, textContent: '' - }; + } as any; textarea = { value: '', style: { left: 0, top: 0 } - }; + } as any; terminal = { element: { querySelector: () => { @@ -54,7 +55,7 @@ describe('CompositionHelper', () => { options: { lineHeight: 1 } - }; + } as any; handledText = ''; compositionHelper = new CompositionHelper(textarea, compositionView, terminal); }); @@ -63,7 +64,7 @@ describe('CompositionHelper', () => { it('Should insert simple characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -71,7 +72,7 @@ describe('CompositionHelper', () => { assert.equal(handledText, 'ㅇ'); // Second character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -87,13 +88,13 @@ describe('CompositionHelper', () => { it('Should insert complex characters', (done) => { // First character '앙' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -101,13 +102,13 @@ describe('CompositionHelper', () => { assert.equal(handledText, '앙'); // Second character '앙' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = '앙ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '앙아'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -127,19 +128,19 @@ describe('CompositionHelper', () => { it('Should insert complex characters that change with following character', (done) => { // First character '아' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아'; setTimeout(() => { // wait for any textarea updates // Start second character '아' in first character - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아아'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -156,14 +157,14 @@ describe('CompositionHelper', () => { it('Should insert multi-characters compositions', (done) => { // First character 'だ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'd' }); + compositionHelper.compositionupdate({ data: 'd' }); textarea.value = 'd'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'だ' }); + compositionHelper.compositionupdate({ data: 'だ' }); textarea.value = 'だ'; setTimeout(() => { // wait for any textarea updates // Second character 'あ' - compositionHelper.compositionupdate({ data: 'だあ' }); + compositionHelper.compositionupdate({ data: 'だあ' }); textarea.value = 'だあ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -179,18 +180,18 @@ describe('CompositionHelper', () => { it('Should insert multi-character compositions that are converted to other characters with the same length', (done) => { // First character 'だ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'd' }); + compositionHelper.compositionupdate({ data: 'd' }); textarea.value = 'd'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'だ' }); + compositionHelper.compositionupdate({ data: 'だ' }); textarea.value = 'だ'; setTimeout(() => { // wait for any textarea updates // Second character 'ー' - compositionHelper.compositionupdate({ data: 'だー' }); + compositionHelper.compositionupdate({ data: 'だー' }); textarea.value = 'だー'; setTimeout(() => { // wait for any textarea updates // Convert to katakana 'ダー' - compositionHelper.compositionupdate({ data: 'ダー' }); + compositionHelper.compositionupdate({ data: 'ダー' }); textarea.value = 'ダー'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -207,18 +208,18 @@ describe('CompositionHelper', () => { it('Should insert multi-character compositions that are converted to other characters with different lengths', (done) => { // First character 'い' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'い' }); + compositionHelper.compositionupdate({ data: 'い' }); textarea.value = 'い'; setTimeout(() => { // wait for any textarea updates // Second character 'ま' - compositionHelper.compositionupdate({ data: 'いm' }); + compositionHelper.compositionupdate({ data: 'いm' }); textarea.value = 'いm'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'いま' }); + compositionHelper.compositionupdate({ data: 'いま' }); textarea.value = 'いま'; setTimeout(() => { // wait for any textarea updates // Convert to kanji '今' - compositionHelper.compositionupdate({ data: '今' }); + compositionHelper.compositionupdate({ data: '今' }); textarea.value = '今'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -235,7 +236,7 @@ describe('CompositionHelper', () => { it('Should insert non-composition characters input immediately after composition characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); diff --git a/src/EscapeSequenceParser.test.ts b/src/EscapeSequenceParser.test.ts index d9ea64ce..5db6d7ff 100644 --- a/src/EscapeSequenceParser.test.ts +++ b/src/EscapeSequenceParser.test.ts @@ -169,12 +169,12 @@ describe('EscapeSequenceParser', function (): void { }); it('constructor', function (): void { let p: EscapeSequenceParser = new EscapeSequenceParser(); - chai.expect(p.transitions).equal(VT500_TRANSITION_TABLE); + chai.expect(p.TRANSITIONS).equal(VT500_TRANSITION_TABLE); p = new EscapeSequenceParser(VT500_TRANSITION_TABLE); - chai.expect(p.transitions).equal(VT500_TRANSITION_TABLE); + chai.expect(p.TRANSITIONS).equal(VT500_TRANSITION_TABLE); const tansitions: TransitionTable = new TransitionTable(10); p = new EscapeSequenceParser(tansitions); - chai.expect(p.transitions).equal(tansitions); + chai.expect(p.TRANSITIONS).equal(tansitions); }); it('inital states', function (): void { chai.expect(parser.initialState).equal(ParserState.GROUND); @@ -237,7 +237,7 @@ describe('EscapeSequenceParser', function (): void { '\x89', '\x8a', '\x8b', '\x8c', '\x8d', '\x8e', '\x8f', '\x91', '\x92', '\x93', '\x94', '\x95', '\x96', '\x97', '\x99', '\x9a' ]; - const exceptions = { + const exceptions: { [key: number]: { [key: string]: any[] } } = { 8: { '\x18': [], '\x1a': [] } // simply abort osc state }; parser.reset(); @@ -247,7 +247,7 @@ describe('EscapeSequenceParser', function (): void { parser.currentState = state; parser.parse(exes[i]); chai.expect(parser.currentState).equal(ParserState.GROUND); - testTerminal.compare(((exceptions[state]) ? exceptions[state][exes[i]] : 0) || [['exe', exes[i]]]); + testTerminal.compare((state in exceptions ? exceptions[state][exes[i]] : 0) || [['exe', exes[i]]]); parser.reset(); testTerminal.clear(); } @@ -1099,20 +1099,20 @@ describe('EscapeSequenceParser', function (): void { describe('set/clear handler', function (): void { const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1b]1;foo=bar\x1b\\'; - let parser2 = null; + let parser2: TestEscapeSequenceParser = null; let print = ''; - let esc = []; - let csi = []; - let exe = []; - let osc = []; - let dcs = []; + const esc: string[] = []; + const csi: [string, number[], string][] = []; + const exe: string[] = []; + const osc: [number, string][] = []; + const dcs: ([string] | [string, string] | [string, string, number[], number])[] = []; function clearAccu(): void { print = ''; - esc = []; - csi = []; - exe = []; - osc = []; - dcs = []; + esc.length = 0; + csi.length = 0; + exe.length = 0; + osc.length = 0; + dcs.length = 0; } beforeEach(function (): void { parser2 = new TestEscapeSequenceParser(); diff --git a/src/EscapeSequenceParser.ts b/src/EscapeSequenceParser.ts index 71a06138..8995b203 100644 --- a/src/EscapeSequenceParser.ts +++ b/src/EscapeSequenceParser.ts @@ -4,6 +4,7 @@ */ import { ParserState, ParserAction, IParsingState, IDcsHandler, IEscapeSequenceParser } from './Types'; +import { Disposable } from './common/Lifecycle'; /** * Returns an array filled with numbers between the low and high parameters (right exclusive). @@ -207,7 +208,7 @@ class DcsDummy implements IDcsHandler { * NOTE: The parameter element notation is currently not supported. * TODO: implement error recovery hook via error handler return values */ -export class EscapeSequenceParser implements IEscapeSequenceParser { +export class EscapeSequenceParser extends Disposable implements IEscapeSequenceParser { public initialState: number; public currentState: number; @@ -235,7 +236,9 @@ export class EscapeSequenceParser implements IEscapeSequenceParser { protected _dcsHandlerFb: IDcsHandler; protected _errorHandlerFb: (state: IParsingState) => IParsingState; - constructor(readonly transitions: TransitionTable = VT500_TRANSITION_TABLE) { + constructor(readonly TRANSITIONS: TransitionTable = VT500_TRANSITION_TABLE) { + super(); + this.initialState = ParserState.GROUND; this.currentState = this.initialState; this._osc = ''; @@ -260,6 +263,24 @@ export class EscapeSequenceParser implements IEscapeSequenceParser { this._errorHandler = this._errorHandlerFb; } + public dispose(): void { + this._printHandlerFb = null; + this._executeHandlerFb = null; + this._csiHandlerFb = null; + this._escHandlerFb = null; + this._oscHandlerFb = null; + this._dcsHandlerFb = null; + this._errorHandlerFb = null; + this._printHandler = null; + this._executeHandlers = null; + this._csiHandlers = null; + this._escHandlers = null; + this._oscHandlers = null; + this._dcsHandlers = null; + this._activeDcsHandler = null; + this._errorHandler = null; + } + setPrintHandler(callback: (data: string, start: number, end: number) => void): void { this._printHandler = callback; } @@ -342,7 +363,7 @@ export class EscapeSequenceParser implements IEscapeSequenceParser { let osc = this._osc; let collect = this._collect; let params = this._params; - const table: Uint8Array | number[] = this.transitions.table; + const table: Uint8Array | number[] = this.TRANSITIONS.table; let dcsHandler: IDcsHandler | null = this._activeDcsHandler; let callback: Function | null = null; diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 9ce31bb5..0de5999a 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -5,11 +5,13 @@ import { XtermListener } from './Types'; import { IEventEmitter, IDisposable } from 'xterm'; +import { Disposable } from './common/Lifecycle'; -export class EventEmitter implements IEventEmitter, IDisposable { +export class EventEmitter extends Disposable implements IEventEmitter, IDisposable { private _events: {[type: string]: XtermListener[]}; constructor() { + super(); // Restore the previous events if available, this will happen if the // constructor is called multiple times on the same object (terminal reset). this._events = this._events || {}; @@ -26,6 +28,7 @@ export class EventEmitter implements IEventEmitter, IDisposable { * @param handler The handler for the listener. */ public addDisposableListener(type: string, handler: XtermListener): IDisposable { + // TODO: Rename addDisposableEventListener to more easily disambiguate from Dom listener this.on(type, handler); return { dispose: () => { @@ -76,6 +79,7 @@ export class EventEmitter implements IEventEmitter, IDisposable { } public dispose(): void { + super.dispose(); this._events = {}; } } diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index 6dc043db..e8099914 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -12,18 +12,22 @@ describe('InputHandler', () => { const terminal = new MockInputHandlingTerminal(); terminal.buffer.x = 1; terminal.buffer.y = 2; + terminal.curAttr = 3; const inputHandler = new InputHandler(terminal); // Save cursor position inputHandler.saveCursor([]); assert.equal(terminal.buffer.x, 1); assert.equal(terminal.buffer.y, 2); + assert.equal(terminal.curAttr, 3); // Change cursor position terminal.buffer.x = 10; terminal.buffer.y = 20; + terminal.curAttr = 30; // Restore cursor position inputHandler.restoreCursor([]); assert.equal(terminal.buffer.x, 1); assert.equal(terminal.buffer.y, 2); + assert.equal(terminal.curAttr, 3); }); describe('setCursorStyle', () => { it('should call Terminal.setOption with correct params', () => { diff --git a/src/InputHandler.ts b/src/InputHandler.ts index f5db133f..bd9ea0c9 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -4,18 +4,20 @@ * @license MIT */ -import { CharData, IInputHandler, IDcsHandler, IEscapeSequenceParser, IBuffer, ICharset } from './Types'; -import { C0, C1 } from './EscapeSequences'; -import { CHARSETS, DEFAULT_CHARSET } from './Charsets'; -import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX } from './Buffer'; +import { CharData, IInputHandler, IDcsHandler, IEscapeSequenceParser, IBuffer } from './Types'; +import { C0, C1 } from './common/data/EscapeSequences'; +import { CHARSETS, DEFAULT_CHARSET } from './core/data/Charsets'; +import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX, DEFAULT_ATTR } from './Buffer'; import { FLAGS } from './renderer/Types'; import { wcwidth } from './CharWidth'; import { EscapeSequenceParser } from './EscapeSequenceParser'; +import { ICharset } from './core/Types'; +import { Disposable } from './common/Lifecycle'; /** * Map collect to glevel. Used in `selectCharset`. */ -const GLEVEL = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2}; +const GLEVEL: {[key: string]: number} = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2}; /** @@ -77,7 +79,7 @@ class DECRQSS implements IDcsHandler { // TODO: report real settings instead of 0m return this._terminal.send(`${C0.ESC}P1$r0m${C0.ESC}\\`); case ' q': // DECSCUSR - const STYLES = {'block': 2, 'underline': 4, 'bar': 6}; + const STYLES: {[key: string]: number} = {'block': 2, 'underline': 4, 'bar': 6}; let style = STYLES[this._terminal.getOption('cursorStyle')]; style -= this._terminal.getOption('cursorBlink'); return this._terminal.send(`${C0.ESC}P1$r${style} q${C0.ESC}\\`); @@ -110,13 +112,17 @@ class DECRQSS implements IDcsHandler { * Refer to http://invisible-island.net/xterm/ctlseqs/ctlseqs.html to understand * each function's header comment. */ -export class InputHandler implements IInputHandler { +export class InputHandler extends Disposable implements IInputHandler { private _surrogateHigh: string; constructor( private _terminal: any, // TODO: reestablish IInputHandlingTerminal here private _parser: IEscapeSequenceParser = new EscapeSequenceParser()) { + super(); + + this.register(this._parser); + this._surrogateHigh = ''; /** @@ -163,12 +169,12 @@ export class InputHandler implements IInputHandler { this._parser.setCsiHandler('X', (params, collect) => this.eraseChars(params)); this._parser.setCsiHandler('Z', (params, collect) => this.cursorBackwardTab(params)); this._parser.setCsiHandler('`', (params, collect) => this.charPosAbsolute(params)); - this._parser.setCsiHandler('a', (params, collect) => this.HPositionRelative(params)); + this._parser.setCsiHandler('a', (params, collect) => this.hPositionRelative(params)); this._parser.setCsiHandler('b', (params, collect) => this.repeatPrecedingCharacter(params)); this._parser.setCsiHandler('c', (params, collect) => this.sendDeviceAttributes(params, collect)); this._parser.setCsiHandler('d', (params, collect) => this.linePosAbsolute(params)); - this._parser.setCsiHandler('e', (params, collect) => this.VPositionRelative(params)); - this._parser.setCsiHandler('f', (params, collect) => this.HVPosition(params)); + this._parser.setCsiHandler('e', (params, collect) => this.vPositionRelative(params)); + this._parser.setCsiHandler('f', (params, collect) => this.hVPosition(params)); this._parser.setCsiHandler('g', (params, collect) => this.tabClear(params)); this._parser.setCsiHandler('h', (params, collect) => this.setMode(params, collect)); this._parser.setCsiHandler('l', (params, collect) => this.resetMode(params, collect)); @@ -284,6 +290,11 @@ export class InputHandler implements IInputHandler { this._parser.setDcsHandler('+q', new RequestTerminfo(this._terminal)); } + public dispose(): void { + super.dispose(); + this._terminal = null; + } + public parse(data: string): void { let buffer = this._terminal.buffer; const cursorStartX = buffer.x; @@ -949,7 +960,7 @@ export class InputHandler implements IInputHandler { * [columns] (default = [row,col+1]) (HPR) * reuse CSI Ps C ? */ - public HPositionRelative(params: number[]): void { + public hPositionRelative(params: number[]): void { let param = params[0]; if (param < 1) { param = 1; @@ -970,7 +981,7 @@ export class InputHandler implements IInputHandler { 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]; + const ch = line[buffer.x - 1] || [DEFAULT_ATTR, ' ', 1, 32]; while (param--) { line[buffer.x++] = ch; @@ -1063,7 +1074,7 @@ export class InputHandler implements IInputHandler { * [rows] (default = [row+1,column]) * reuse CSI Ps B ? */ - public VPositionRelative(params: number[]): void { + public vPositionRelative(params: number[]): void { let param = params[0]; if (param < 1) { param = 1; @@ -1083,7 +1094,7 @@ export class InputHandler implements IInputHandler { * Horizontal and Vertical Position [row;column] (default = * [1,1]) (HVP). */ - public HVPosition(params: number[]): void { + public hVPosition(params: number[]): void { if (params[0] < 1) params[0] = 1; if (params[1] < 1) params[1] = 1; @@ -1553,7 +1564,7 @@ export class InputHandler implements IInputHandler { public charAttributes(params: number[]): void { // Optimize a single SGR0. if (params.length === 1 && params[0] === 0) { - this._terminal.curAttr = this._terminal.defAttr; + this._terminal.curAttr = DEFAULT_ATTR; return; } @@ -1581,9 +1592,9 @@ export class InputHandler implements IInputHandler { bg = p - 100; } else if (p === 0) { // default - flags = this._terminal.defAttr >> 18; - fg = (this._terminal.defAttr >> 9) & 0x1ff; - bg = this._terminal.defAttr & 0x1ff; + flags = DEFAULT_ATTR >> 18; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; // flags = 0; // fg = 0x1ff; // bg = 0x1ff; @@ -1627,10 +1638,10 @@ export class InputHandler implements IInputHandler { flags &= ~FLAGS.INVISIBLE; } else if (p === 39) { // reset fg - fg = (this._terminal.defAttr >> 9) & 0x1ff; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; } else if (p === 49) { // reset bg - bg = this._terminal.defAttr & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; } else if (p === 38) { // fg color 256 if (params[i + 1] === 2) { @@ -1663,8 +1674,8 @@ export class InputHandler implements IInputHandler { } } else if (p === 100) { // reset fg/bg - fg = (this._terminal.defAttr >> 9) & 0x1ff; - bg = this._terminal.defAttr & 0x1ff; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; } else { this._terminal.error('Unknown SGR attribute: %d.', p); } @@ -1759,7 +1770,7 @@ export class InputHandler implements IInputHandler { this._terminal.applicationCursor = false; this._terminal.buffer.scrollTop = 0; this._terminal.buffer.scrollBottom = this._terminal.rows - 1; - this._terminal.curAttr = this._terminal.defAttr; + this._terminal.curAttr = DEFAULT_ATTR; this._terminal.buffer.x = this._terminal.buffer.y = 0; // ? this._terminal.charset = null; this._terminal.glevel = 0; // ?? @@ -1822,6 +1833,7 @@ export class InputHandler implements IInputHandler { public saveCursor(params: number[]): void { this._terminal.buffer.savedX = this._terminal.buffer.x; this._terminal.buffer.savedY = this._terminal.buffer.y; + this._terminal.savedCurAttr = this._terminal.curAttr; } @@ -1833,6 +1845,7 @@ export class InputHandler implements IInputHandler { public restoreCursor(params: number[]): void { this._terminal.buffer.x = this._terminal.buffer.savedX || 0; this._terminal.buffer.y = this._terminal.buffer.savedY || 0; + this._terminal.curAttr = this._terminal.savedCurAttr || DEFAULT_ATTR; } diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index 12d40bfe..7f4d5603 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -4,16 +4,16 @@ */ import { assert } from 'chai'; -import { IMouseZoneManager, IMouseZone } from './input/Types'; +import { IMouseZoneManager, IMouseZone } from './ui/Types'; import { ILinkMatcher, LineData, ITerminal } from './Types'; import { Linkifier } from './Linkifier'; import { MockBuffer, MockTerminal } from './utils/TestUtils.test'; -import { CircularList } from './utils/CircularList'; +import { CircularList } from './common/CircularList'; class TestLinkifier extends Linkifier { - constructor(_terminal: ITerminal) { - super(_terminal); - Linkifier.TIME_BEFORE_LINKIFY = 0; + constructor(terminal: ITerminal) { + super(terminal); + (Linkifier).TIME_BEFORE_LINKIFY = 0; } public get linkMatchers(): ILinkMatcher[] { return this._linkMatchers; } @@ -21,6 +21,8 @@ class TestLinkifier extends Linkifier { } class TestMouseZoneManager implements IMouseZoneManager { + dispose(): void { + } public clears: number = 0; public zones: IMouseZone[] = []; add(zone: IMouseZone): void { diff --git a/src/Linkifier.ts b/src/Linkifier.ts index ee7c2626..88ff1b78 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -3,9 +3,9 @@ * @license MIT */ -import { IMouseZoneManager } from './input/Types'; +import { IMouseZoneManager } from './ui/Types'; import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal } from './Types'; -import { MouseZone } from './input/MouseZoneManager'; +import { MouseZone } from './ui/MouseZoneManager'; import { EventEmitter } from './EventEmitter'; /** @@ -17,7 +17,7 @@ export class Linkifier extends EventEmitter implements ILinkifier { * the costly operation of searching every row multiple times, potentially a * huge amount of times. */ - protected static TIME_BEFORE_LINKIFY = 200; + protected static readonly TIME_BEFORE_LINKIFY = 200; protected _linkMatchers: ILinkMatcher[] = []; diff --git a/src/Parser.ts b/src/Parser.ts index d3241307..4086de19 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -4,9 +4,9 @@ * @license MIT */ -import { C0 } from './EscapeSequences'; +import { C0 } from './common/data/EscapeSequences'; import { IInputHandler, IInputHandlingTerminal } from './Types'; -import { CHARSETS, DEFAULT_CHARSET } from './Charsets'; +import { CHARSETS, DEFAULT_CHARSET } from './core/data/Charsets'; const normalStateHandler: {[key: string]: (parser: Parser, handler: IInputHandler) => void} = {}; normalStateHandler[C0.BEL] = (parser, handler) => handler.bell(); @@ -124,12 +124,12 @@ csiStateHandler['T'] = (handler, params, prefix) => { csiStateHandler['X'] = (handler, params, prefix) => handler.eraseChars(params); csiStateHandler['Z'] = (handler, params, prefix) => handler.cursorBackwardTab(params); csiStateHandler['`'] = (handler, params, prefix) => handler.charPosAbsolute(params); -csiStateHandler['a'] = (handler, params, prefix) => handler.HPositionRelative(params); +csiStateHandler['a'] = (handler, params, prefix) => handler.hPositionRelative(params); csiStateHandler['b'] = (handler, params, prefix) => handler.repeatPrecedingCharacter(params); csiStateHandler['c'] = (handler, params, prefix) => handler.sendDeviceAttributes(params); csiStateHandler['d'] = (handler, params, prefix) => handler.linePosAbsolute(params); -csiStateHandler['e'] = (handler, params, prefix) => handler.VPositionRelative(params); -csiStateHandler['f'] = (handler, params, prefix) => handler.HVPosition(params); +csiStateHandler['e'] = (handler, params, prefix) => handler.vPositionRelative(params); +csiStateHandler['f'] = (handler, params, prefix) => handler.hVPosition(params); csiStateHandler['g'] = (handler, params, prefix) => handler.tabClear(params); csiStateHandler['h'] = (handler, params, prefix) => handler.setMode(params); csiStateHandler['l'] = (handler, params, prefix) => handler.resetMode(params); diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 06cea766..4ae0c08f 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -4,8 +4,8 @@ */ import { assert } from 'chai'; -import { CharMeasure } from './utils/CharMeasure'; -import { SelectionManager } from './SelectionManager'; +import { CharMeasure } from './ui/CharMeasure'; +import { SelectionManager, SelectionMode } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; import { LineData, CharData, ITerminal, IBuffer } from './Types'; @@ -25,6 +25,8 @@ class TestSelectionManager extends SelectionManager { public get model(): SelectionModel { return this._model; } + public set selectionMode(mode: SelectionMode) { this._activeSelectionMode = mode; } + public selectLineAt(line: number): void { this._selectLineAt(line); } public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } @@ -298,6 +300,16 @@ describe('SelectionManager', () => { assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); assert.deepEqual(selectionManager.model.finalSelectionEnd, [terminal.cols, 0], 'The actual selection spans the entire column'); }); + it('should select the entire wrapped line', () => { + buffer.lines.set(0, stringToRow('foo')); + const line2 = stringToRow('bar'); + (line2).isWrapped = true; + buffer.lines.set(1, line2); + selectionManager.selectLineAt(0); + assert.equal(selectionManager.selectionText, 'foobar', 'The selected text is correct'); + assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionManager.model.finalSelectionEnd, [terminal.cols, 1], 'The actual selection spans the entire column'); + }); }); describe('selectAll', () => { @@ -368,4 +380,61 @@ describe('SelectionManager', () => { assert.equal(selectionManager.hasSelection, true); }); }); + + describe('column selection', () => { + it('should select a column of text', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('abcdefghij')); + buffer.lines.set(1, stringToRow('klmnopqrst')); + buffer.lines.set(2, stringToRow('uvwxyz')); + + selectionManager.selectionMode = SelectionMode.COLUMN; + selectionManager.model.selectionStart = [2, 0]; + selectionManager.model.selectionEnd = [4, 2]; + + assert.equal(selectionManager.selectionText, 'cd\nmn\nwx'); + }); + + it('should select a column of text without chopping up double width characters', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a')); + buffer.lines.set(1, stringToRow('語')); + buffer.lines.set(2, stringToRow('b')); + + selectionManager.selectionMode = SelectionMode.COLUMN; + selectionManager.model.selectionStart = [0, 0]; + selectionManager.model.selectionEnd = [1, 2]; + + assert.equal(selectionManager.selectionText, 'a\n語\nb'); + }); + + it('should select a column of text with single character emojis', () => { + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a')); + buffer.lines.set(1, stringToRow('☃')); + buffer.lines.set(2, stringToRow('c')); + + selectionManager.selectionMode = SelectionMode.COLUMN; + selectionManager.model.selectionStart = [0, 0]; + selectionManager.model.selectionEnd = [1, 2]; + + assert.equal(selectionManager.selectionText, 'a\n☃\nc'); + }); + + it('should select a column of text with double character emojis', () => { + // TODO the case this is testing works for me in the demo webapp, + // but doing it programmatically fails. + buffer.lines.length = 3; + buffer.lines.set(0, stringToRow('a ')); + buffer.lines.set(1, stringArrayToRow(['😁', ' '])); + buffer.lines.set(2, stringToRow('c ')); + + selectionManager.selectionMode = SelectionMode.COLUMN; + selectionManager.model.selectionStart = [0, 0]; + selectionManager.model.selectionEnd = [1, 2]; + + assert.equal(selectionManager.selectionText, 'a\n😁\nc'); + }); + }); }); + diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index c150554f..1de14e70 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -6,7 +6,7 @@ import { ITerminal, ISelectionManager, IBuffer, CharData, XtermListener } from './Types'; import { MouseHelper } from './utils/MouseHelper'; import * as Browser from './shared/utils/Browser'; -import { CharMeasure } from './utils/CharMeasure'; +import { CharMeasure } from './ui/CharMeasure'; import { EventEmitter } from './EventEmitter'; import { SelectionModel } from './SelectionModel'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; @@ -54,10 +54,11 @@ interface IWordPosition { /** * A selection mode, this drives how the selection behaves on mouse move. */ -const enum SelectionMode { +export const enum SelectionMode { NORMAL, WORD, - LINE + LINE, + COLUMN } /** @@ -80,7 +81,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager /** * The current selection mode. */ - private _activeSelectionMode: SelectionMode; + protected _activeSelectionMode: SelectionMode; /** * A setInterval timer that is active while the mouse is down whose callback @@ -116,6 +117,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager this._activeSelectionMode = SelectionMode.NORMAL; } + public dispose(): void { + super.dispose(); + this._removeMouseDownListeners(); + } + private get _buffer(): IBuffer { return this._terminal.buffers.active; } @@ -177,30 +183,43 @@ export class SelectionManager extends EventEmitter implements ISelectionManager return ''; } - // Get first row - const startRowEndCol = start[1] === end[1] ? end[0] : null; const result: string[] = []; - result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol)); - // Get middle rows - for (let i = start[1] + 1; i <= end[1] - 1; i++) { - const bufferLine = this._buffer.lines.get(i); - const lineText = this._buffer.translateBufferLineToString(i, true); - if ((bufferLine).isWrapped) { - result[result.length - 1] += lineText; - } else { + if (this._activeSelectionMode === SelectionMode.COLUMN) { + // Ignore zero width selections + if (start[0] === end[0]) { + return ''; + } + + for (let i = start[1]; i <= end[1]; i++) { + const lineText = this._buffer.translateBufferLineToString(i, true, start[0], end[0]); result.push(lineText); } - } + } else { + // Get first row + const startRowEndCol = start[1] === end[1] ? end[0] : null; + result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol)); - // Get final row - if (start[1] !== end[1]) { - const bufferLine = this._buffer.lines.get(end[1]); - const lineText = this._buffer.translateBufferLineToString(end[1], true, 0, end[0]); - if ((bufferLine).isWrapped) { - result[result.length - 1] += lineText; - } else { - result.push(lineText); + // Get middle rows + for (let i = start[1] + 1; i <= end[1] - 1; i++) { + const bufferLine = this._buffer.lines.get(i); + const lineText = this._buffer.translateBufferLineToString(i, true); + if ((bufferLine).isWrapped) { + result[result.length - 1] += lineText; + } else { + result.push(lineText); + } + } + + // Get final row + if (start[1] !== end[1]) { + const bufferLine = this._buffer.lines.get(end[1]); + const lineText = this._buffer.translateBufferLineToString(end[1], true, 0, end[0]); + if ((bufferLine).isWrapped) { + result[result.length - 1] += lineText; + } else { + result.push(lineText); + } } } @@ -249,7 +268,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager */ private _refresh(): void { this._refreshAnimationFrame = null; - this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); + this.emit('refresh', { + start: this._model.finalSelectionStart, + end: this._model.finalSelectionEnd, + columnSelectMode: this._activeSelectionMode === SelectionMode.COLUMN + }); } /** @@ -358,7 +381,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ public shouldForceSelection(event: MouseEvent): boolean { - return Browser.isMac ? event.altKey : event.shiftKey; + if (Browser.isMac) { + return event.altKey && this._terminal.options.macOptionClickForcesSelection; + } + + return event.shiftKey; } /** @@ -449,7 +476,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager private _onSingleClick(event: MouseEvent): void { this._model.selectionStartLength = 0; this._model.isSelectAllActive = false; - this._activeSelectionMode = SelectionMode.NORMAL; + this._activeSelectionMode = this.shouldColumnSelect(event) ? SelectionMode.COLUMN : SelectionMode.NORMAL; // Initialize the new selection this._model.selectionStart = this._getMouseBufferCoords(event); @@ -502,6 +529,14 @@ export class SelectionManager extends EventEmitter implements ISelectionManager } } + /** + * Returns whether the selection manager should operate in column select mode + * @param event the mouse or keyboard event + */ + public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean { + return event.altKey && !(Browser.isMac && this._terminal.options.macOptionClickForcesSelection); + } + /** * Handles the mousemove event when the mouse button is down, recording the * end of the selection and refreshing the selection. @@ -803,7 +838,9 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param line The line index. */ protected _selectLineAt(line: number): void { - this._model.selectionStart = [0, line]; - this._model.selectionStartLength = this._terminal.cols; + const wrappedRange = this._buffer.getWrappedRangeForLine(line); + this._model.selectionStart = [0, wrappedRange.first]; + this._model.selectionEnd = [this._terminal.cols, wrappedRange.last]; + this._model.selectionStartLength = 0; } } diff --git a/src/Terminal.integration.ts b/src/Terminal.integration.ts index 54e76518..f37d7840 100644 --- a/src/Terminal.integration.ts +++ b/src/Terminal.integration.ts @@ -14,6 +14,11 @@ import * as pty from 'node-pty'; import { assert } from 'chai'; import { Terminal } from './Terminal'; import { CHAR_DATA_CHAR_INDEX } from './Buffer'; +import { IViewport } from './Types'; + +class TestTerminal extends Terminal { + innerWrite(): void { this._innerWrite(); } +} let primitivePty: any; @@ -74,25 +79,25 @@ function terminalToString(term: Terminal): string { // Skip tests on Windows since pty.open isn't supported if (os.platform() !== 'win32') { - const CONSOLE_LOG = console.log; + const consoleLog = console.log; // expect files need terminal at 80x25! - const COLS = 80; - const ROWS = 25; + const cols = 80; + const rows = 25; /** some helpers for pty interaction */ // we need a pty in between to get the termios decorations // for the basic test cases a raw pty device is enough - primitivePty = pty.native.open(COLS, ROWS); + primitivePty = (pty).native.open(cols, rows); /** tests */ describe('xterm output comparison', () => { - let xterm; + let xterm: TestTerminal; beforeEach(() => { - xterm = new Terminal({ cols: COLS, rows: ROWS }); + xterm = new TestTerminal({ cols: cols, rows: rows }); xterm.refresh = () => {}; - xterm.viewport = { + xterm.viewport = { syncScrollArea: () => {} }; }); @@ -125,11 +130,12 @@ if (os.platform() !== 'win32') { // Perform a synchronous .write(data) xterm.writeBuffer.push(fromPty); - xterm._innerWrite(); + xterm.innerWrite(); const fromEmulator = terminalToString(xterm); - console.log = CONSOLE_LOG; + console.log = consoleLog; const expected = fs.readFileSync(filename.split('.')[0] + '.text', 'utf8'); + // Some of the tests have whitespace on the right of lines, we trim all the linex // from xterm.js so ignore this for now at least. const expectedRightTrimmed = expected.split('\n').map(l => l.replace(/\s+$/, '')).join('\n'); diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index 5b7039e9..0ea854e2 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -5,7 +5,6 @@ import { assert, expect } from 'chai'; import { Terminal } from './Terminal'; -import * as attach from './addons/attach/attach'; import { MockViewport, MockCompositionHelper, MockRenderer } from './utils/TestUtils.test'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from './Buffer'; @@ -13,7 +12,6 @@ const INIT_COLS = 80; const INIT_ROWS = 24; class TestTerminal extends Terminal { - public evaluateKeyEscapeSequence(ev: any): {cancel: boolean, key: string, scrollLines: number} { return this._evaluateKeyEscapeSequence(ev); } public keyDown(ev: any): boolean { return this._keyDown(ev); } public keyPress(ev: any): boolean { return this._keyPress(ev); } } @@ -53,13 +51,6 @@ describe('term.js addons', () => { }); }); - it('should apply addons with Terminal.applyAddon', () => { - Terminal.applyAddon(attach); - // Test that addon was applied successfully, adding attach to Terminal's - // prototype. - assert.equal(typeof (Terminal).prototype.attach, 'function'); - }); - describe('getOption', () => { it('should retrieve the option correctly', () => { // In the `options` namespace. @@ -129,6 +120,17 @@ describe('term.js addons', () => { }); }); + describe('reset', () => { + it('should not affect cursorState', () => { + term.cursorState = 1; + term.reset(); + assert.equal(term.cursorState, 1); + term.cursorState = 0; + term.reset(); + assert.equal(term.cursorState, 0); + }); + }); + describe('clear', () => { it('should clear a buffer equal to rows', () => { const promptLine = term.buffer.lines.get(term.buffer.ybase + term.buffer.y); @@ -176,7 +178,7 @@ describe('term.js addons', () => { describe('scroll', () => { describe('scrollLines', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 2; i++) { term.writeln('test'); @@ -211,7 +213,7 @@ describe('term.js addons', () => { }); describe('scrollPages', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -248,7 +250,7 @@ describe('term.js addons', () => { }); describe('scrollToBottom', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -269,7 +271,7 @@ describe('term.js addons', () => { }); describe('scrollToLine', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -296,22 +298,19 @@ describe('term.js addons', () => { }); }); - describe('keyDown', () => { + describe('keyPress', () => { it('should scroll down, when a key is pressed and terminal is scrolled up', () => { - // Override _evaluateKeyEscapeSequence to return cancel code - (term)._evaluateKeyEscapeSequence = () => { - return { key: 'a' }; - }; const event = { type: 'keydown', - keyCode: 0, + key: 'a', + keyCode: 65, preventDefault: () => {}, stopPropagation: () => {} }; term.buffer.ydisp = 0; term.buffer.ybase = 40; - term.keyDown(event); + term.keyPress(event); // Ensure that now the terminal is scrolled to bottom assert.equal(term.buffer.ydisp, term.buffer.ybase); @@ -330,7 +329,7 @@ describe('term.js addons', () => { assert.equal(term.buffer.ydisp, startYDisp); term.scrollLines(-1); assert.equal(term.buffer.ydisp, startYDisp - 1); - term.keyDown({ keyCode: 0 }); + term.keyPress({ keyCode: 0 }); assert.equal(term.buffer.ydisp, startYDisp - 1); }); }); @@ -468,252 +467,9 @@ describe('term.js addons', () => { }); }); - describe('evaluateKeyEscapeSequence', () => { - it('should return the correct escape sequence for unmodified keys', () => { - // Backspace - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 8 }).key, '\x7f'); // ^? - // Tab - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 9 }).key, '\t'); - // Return/enter - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 13 }).key, '\r'); // CR - // Escape - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 27 }).key, '\x1b'); - // Page up, page down - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 33 }).key, '\x1b[5~'); // CSI 5 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 34 }).key, '\x1b[6~'); // CSI 6 ~ - // End, Home - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 35 }).key, '\x1b[F'); // SS3 F - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 36 }).key, '\x1b[H'); // SS3 H - // Left, up, right, down arrows - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 37 }).key, '\x1b[D'); // CSI D - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 38 }).key, '\x1b[A'); // CSI A - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 39 }).key, '\x1b[C'); // CSI C - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 40 }).key, '\x1b[B'); // CSI B - // Insert - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 45 }).key, '\x1b[2~'); // CSI 2 ~ - // Delete - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 46 }).key, '\x1b[3~'); // CSI 3 ~ - // F1-F12 - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 112 }).key, '\x1bOP'); // SS3 P - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 113 }).key, '\x1bOQ'); // SS3 Q - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 114 }).key, '\x1bOR'); // SS3 R - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 115 }).key, '\x1bOS'); // SS3 S - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 116 }).key, '\x1b[15~'); // CSI 1 5 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 117 }).key, '\x1b[17~'); // CSI 1 7 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 118 }).key, '\x1b[18~'); // CSI 1 8 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 119 }).key, '\x1b[19~'); // CSI 1 9 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 120 }).key, '\x1b[20~'); // CSI 2 0 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 121 }).key, '\x1b[21~'); // CSI 2 1 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 122 }).key, '\x1b[23~'); // CSI 2 3 ~ - assert.equal(term.evaluateKeyEscapeSequence({ keyCode: 123 }).key, '\x1b[24~'); // CSI 2 4 ~ - }); - it('should return \\x1b[3;5~ for ctrl+delete', () => { - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 46 }).key, '\x1b[3;5~'); - }); - it('should return \\x1b[3;2~ for shift+delete', () => { - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 46 }).key, '\x1b[3;2~'); - }); - it('should return \\x1b[3;3~ for alt+delete', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 46 }).key, '\x1b[3;3~'); - }); - it('should return \\x1b[5D for ctrl+left', () => { - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 37 }).key, '\x1b[1;5D'); // CSI 5 D - }); - it('should return \\x1b[5C for ctrl+right', () => { - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 39 }).key, '\x1b[1;5C'); // CSI 5 C - }); - it('should return \\x1b[5A for ctrl+up', () => { - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 38 }).key, '\x1b[1;5A'); // CSI 5 A - }); - it('should return \\x1b[5B for ctrl+down', () => { - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 40 }).key, '\x1b[1;5B'); // CSI 5 B - }); - - describe('On non-macOS platforms', () => { - beforeEach(() => { - term.browser.isMac = false; - }); - // Evalueate alt + arrow key movement, which is a feature of terminal emulators but not VT100 - // http://unix.stackexchange.com/a/108106 - it('should return \\x1b[5D for alt+left', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 37 }).key, '\x1b[1;5D'); // CSI 5 D - }); - it('should return \\x1b[5C for alt+right', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 39 }).key, '\x1b[1;5C'); // CSI 5 C - }); - it('should return \\x1ba for alt+a', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 65 }).key, '\x1ba'); - }); - }); - - describe('On macOS platforms', () => { - beforeEach(() => { - term.browser.isMac = true; - }); - it('should return \\x1bb for alt+left', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 37 }).key, '\x1bb'); // CSI 5 D - }); - it('should return \\x1bf for alt+right', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 39 }).key, '\x1bf'); // CSI 5 C - }); - it('should return undefined for alt+a', () => { - assert.strictEqual(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 65 }).key, undefined); - }); - }); - - describe('with macOptionIsMeta', () => { - beforeEach(() => { - term.browser.isMac = true; - term.setOption('macOptionIsMeta', true); - }); - it('should return \\x1ba for alt+a', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 65 }).key, '\x1ba'); - }); - }); - - it('should return \\x1b[5A for alt+up', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 38 }).key, '\x1b[1;5A'); // CSI 5 A - }); - it('should return \\x1b[5B for alt+down', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 40 }).key, '\x1b[1;5B'); // CSI 5 B - }); - it('should return the correct escape sequence for modified F1-F12 keys', () => { - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 112 }).key, '\x1b[1;2P'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 113 }).key, '\x1b[1;2Q'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 114 }).key, '\x1b[1;2R'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 115 }).key, '\x1b[1;2S'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 116 }).key, '\x1b[15;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 117 }).key, '\x1b[17;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 118 }).key, '\x1b[18;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 119 }).key, '\x1b[19;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 120 }).key, '\x1b[20;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 121 }).key, '\x1b[21;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 122 }).key, '\x1b[23;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ shiftKey: true, keyCode: 123 }).key, '\x1b[24;2~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 112 }).key, '\x1b[1;3P'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 113 }).key, '\x1b[1;3Q'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 114 }).key, '\x1b[1;3R'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 115 }).key, '\x1b[1;3S'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 116 }).key, '\x1b[15;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 117 }).key, '\x1b[17;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 118 }).key, '\x1b[18;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 119 }).key, '\x1b[19;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 120 }).key, '\x1b[20;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 121 }).key, '\x1b[21;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 122 }).key, '\x1b[23;3~'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, keyCode: 123 }).key, '\x1b[24;3~'); - - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 112 }).key, '\x1b[1;5P'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 113 }).key, '\x1b[1;5Q'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 114 }).key, '\x1b[1;5R'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 115 }).key, '\x1b[1;5S'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 116 }).key, '\x1b[15;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 117 }).key, '\x1b[17;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 118 }).key, '\x1b[18;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 119 }).key, '\x1b[19;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 120 }).key, '\x1b[20;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 121 }).key, '\x1b[21;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 122 }).key, '\x1b[23;5~'); - assert.equal(term.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 123 }).key, '\x1b[24;5~'); - }); - - // Characters using ctrl+alt sequences - it('should return proper sequence for ctrl+alt+a', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, ctrlKey: true, keyCode: 65 }).key, '\x1b\x01'); - }); - - // Characters using alt sequences (numbers) - it('should return proper sequences for alt+0', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 48 }).key, '\x1b0'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 48 }).key, '\x1b)'); - }); - it('should return proper sequences for alt+1', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 49 }).key, '\x1b1'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 49 }).key, '\x1b!'); - }); - it('should return proper sequences for alt+2', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 50 }).key, '\x1b2'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 50 }).key, '\x1b@'); - }); - it('should return proper sequences for alt+3', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 51 }).key, '\x1b3'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 51 }).key, '\x1b#'); - }); - it('should return proper sequences for alt+4', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 52 }).key, '\x1b4'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 52 }).key, '\x1b$'); - }); - it('should return proper sequences for alt+5', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 53 }).key, '\x1b5'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 53 }).key, '\x1b%'); - }); - it('should return proper sequences for alt+6', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 54 }).key, '\x1b6'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 54 }).key, '\x1b^'); - }); - it('should return proper sequences for alt+7', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 55 }).key, '\x1b7'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 55 }).key, '\x1b&'); - }); - it('should return proper sequences for alt+8', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 56 }).key, '\x1b8'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 56 }).key, '\x1b*'); - }); - it('should return proper sequences for alt+9', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 57 }).key, '\x1b9'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 57 }).key, '\x1b('); - }); - - // Characters using alt sequences (special chars) - it('should return proper sequences for alt+;', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 186 }).key, '\x1b;'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 186 }).key, '\x1b:'); - }); - it('should return proper sequences for alt+=', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 187 }).key, '\x1b='); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 187 }).key, '\x1b+'); - }); - it('should return proper sequences for alt+,', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 188 }).key, '\x1b,'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 188 }).key, '\x1b<'); - }); - it('should return proper sequences for alt+-', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 189 }).key, '\x1b-'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 189 }).key, '\x1b_'); - }); - it('should return proper sequences for alt+.', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 190 }).key, '\x1b.'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 190 }).key, '\x1b>'); - }); - it('should return proper sequences for alt+/', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 191 }).key, '\x1b/'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 191 }).key, '\x1b?'); - }); - it('should return proper sequences for alt+~', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 192 }).key, '\x1b`'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 192 }).key, '\x1b~'); - }); - it('should return proper sequences for alt+[', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 219 }).key, '\x1b['); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 219 }).key, '\x1b{'); - }); - it('should return proper sequences for alt+\\', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 220 }).key, '\x1b\\'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 220 }).key, '\x1b|'); - }); - it('should return proper sequences for alt+]', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 221 }).key, '\x1b]'); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 221 }).key, '\x1b}'); - }); - it('should return proper sequences for alt+\'', () => { - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: false, keyCode: 222 }).key, '\x1b\''); - assert.equal(term.evaluateKeyEscapeSequence({ altKey: true, shiftKey: true, keyCode: 222 }).key, '\x1b"'); - }); - }); - describe('Third level shift', () => { - let evKeyDown; - let evKeyPress; + let evKeyDown: any; + let evKeyPress: any; beforeEach(() => { term.handler = () => {}; diff --git a/src/Terminal.ts b/src/Terminal.ts index 9a47b0e2..ad33b35e 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -21,62 +21,38 @@ * http://linux.die.net/man/7/urxvt */ -import { ICharset, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData, CharacterJoinerHandler } from './Types'; -import { IMouseZoneManager } from './input/Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData, CharacterJoinerHandler } from './Types'; +import { IMouseZoneManager } from './ui/Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; -import { Buffer, MAX_BUFFER_SIZE } from './Buffer'; +import { Buffer, MAX_BUFFER_SIZE, DEFAULT_ATTR } from './Buffer'; import { CompositionHelper } from './CompositionHelper'; import { EventEmitter } from './EventEmitter'; import { Viewport } from './Viewport'; import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './handlers/Clipboard'; -import { C0 } from './EscapeSequences'; +import { C0 } from './common/data/EscapeSequences'; import { InputHandler } from './InputHandler'; // import { Parser } from './Parser'; import { Renderer } from './renderer/Renderer'; import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; -import { CharMeasure } from './utils/CharMeasure'; +import { CharMeasure } from './ui/CharMeasure'; import * as Browser from './shared/utils/Browser'; -import * as Dom from './utils/Dom'; +import { addDisposableDomListener } from './ui/Lifecycle'; import * as Strings from './Strings'; import { MouseHelper } from './utils/MouseHelper'; import { clone } from './utils/Clone'; import { DEFAULT_BELL_SOUND, SoundManager } from './SoundManager'; import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; -import { MouseZoneManager } from './input/MouseZoneManager'; +import { MouseZoneManager } from './ui/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; -import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; -import { ITheme, ILocalizableStrings, IMarker, IDisposable } from 'xterm'; +import { ScreenDprMonitor } from './ui/ScreenDprMonitor'; +import { ITheme, IMarker, IDisposable } from 'xterm'; import { removeTerminalFromCache } from './renderer/atlas/CharAtlasCache'; - -// reg + shift key mappings for digits and special chars -const KEYCODE_KEY_MAPPINGS = { - // digits 0-9 - 48: ['0', ')'], - 49: ['1', '!'], - 50: ['2', '@'], - 51: ['3', '#'], - 52: ['4', '$'], - 53: ['5', '%'], - 54: ['6', '^'], - 55: ['7', '&'], - 56: ['8', '*'], - 57: ['9', '('], - - // special chars - 186: [';', ':'], - 187: ['=', '+'], - 188: [',', '<'], - 189: ['-', '_'], - 190: ['.', '>'], - 191: ['/', '?'], - 192: ['`', '~'], - 219: ['[', '{'], - 220: ['\\', '|'], - 221: [']', '}'], - 222: ['\'', '"'] -}; +import { DomRenderer } from './renderer/dom/DomRenderer'; +import { IKeyboardEvent } from './common/Types'; +import { evaluateKeyboardEvent } from './core/input/Keyboard'; +import { KeyboardResultType, ICharset } from './core/Types'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -94,6 +70,11 @@ const WRITE_BUFFER_PAUSE_THRESHOLD = 5; */ const WRITE_BATCH_SIZE = 300; +/** + * The set of options that only have an effect when set in the Terminal constructor. + */ +const CONSTRUCTOR_ONLY_OPTIONS = ['cols', 'rows', 'rendererType']; + const DEFAULT_OPTIONS: ITerminalOptions = { cols: 80, rows: 24, @@ -117,13 +98,15 @@ const DEFAULT_OPTIONS: ITerminalOptions = { screenReaderMode: false, debug: false, macOptionIsMeta: false, + macOptionClickForcesSelection: false, cancelEvents: false, disableStdin: false, useFlowControl: false, allowTransparency: false, tabStopWidth: 8, theme: null, - rightClickSelectsWord: Browser.isMac + rightClickSelectsWord: Browser.isMac, + rendererType: 'canvas' }; export class Terminal extends EventEmitter implements ITerminal, IDisposable, IInputHandlingTerminal { @@ -131,8 +114,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II public element: HTMLElement; public screenElement: HTMLElement; - private _disposables: IDisposable[]; - /** * The HTMLElement that the terminal is created in, set by Terminal.open. */ @@ -190,8 +171,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II private _refreshEnd: number; public savedCols: number; - public defAttr: number; public curAttr: number; + public savedCurAttr: number; public params: (string | number)[]; public currentParam: string | number; @@ -253,8 +234,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II public dispose(): void { super.dispose(); - this._disposables.forEach(d => d.dispose()); - this._disposables.length = 0; + this._customKeyEventHandler = null; removeTerminalFromCache(this); this.handler = () => {}; this.write = () => {}; @@ -271,14 +251,10 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } private _setup(): void { - this._disposables = []; - Object.keys(DEFAULT_OPTIONS).forEach((key) => { if (this.options[key] == null) { this.options[key] = DEFAULT_OPTIONS[key]; } - // TODO: We should move away from duplicate options on the Terminal object - this[key] = this.options[key]; }); // this.context = options.context || window; @@ -313,8 +289,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // TODO: Can this be just []? this.charsets = [null]; - this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); - this.curAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = DEFAULT_ATTR; this.params = []; this.currentParam = 0; @@ -328,6 +303,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this._userScrolling = false; this._inputHandler = new InputHandler(this); + this.register(this._inputHandler); // Reuse renderer if the Terminal is being recreated via a reset call. this.renderer = this.renderer || null; this.selectionManager = this.selectionManager || null; @@ -350,16 +326,12 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return this.buffers.active; } - public static get strings(): ILocalizableStrings { - return Strings; - } - /** * back_color_erase feature for xterm. */ public eraseAttr(): number { - // if (this.is('screen')) return this.defAttr; - return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); + // if (this.is('screen')) return DEFAULT_ATTR; + return (DEFAULT_ATTR & ~0x1ff) | (this.curAttr & 0x1ff); } /** @@ -384,11 +356,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II throw new Error('No option with key "' + key + '"'); } - if (typeof this.options[key] !== 'undefined') { - return this.options[key]; - } - - return this[key]; + return this.options[key]; } /** @@ -400,6 +368,9 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (!(key in DEFAULT_OPTIONS)) { throw new Error('No option with key "' + key + '"'); } + if (CONSTRUCTOR_ONLY_OPTIONS.indexOf(key) !== -1) { + console.error(`Option "${key}" can only be set in the constructor`); + } switch (key) { case 'bellStyle': if (!value) { @@ -462,7 +433,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } break; } - this[key] = value; this.options[key] = value; switch (key) { case 'fontFamily': @@ -473,6 +443,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.charMeasure.measure(this.options); } break; + case 'drawBoldTextInBrightColors': case 'experimentalCharAtlas': case 'enableBold': case 'letterSpacing': @@ -553,30 +524,30 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this._bindKeys(); // Bind clipboard functionality - on(this.element, 'copy', (event: ClipboardEvent) => { + this.register(addDisposableDomListener(this.element, 'copy', (event: ClipboardEvent) => { // If mouse events are active it means the selection manager is disabled and // copy should be handled by the host program. if (!this.hasSelection()) { return; } copyHandler(event, this, this.selectionManager); - }); - const pasteHandlerWrapper = event => pasteHandler(event, this); - on(this.textarea, 'paste', pasteHandlerWrapper); - on(this.element, 'paste', pasteHandlerWrapper); + })); + const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this); + this.register(addDisposableDomListener(this.textarea, 'paste', pasteHandlerWrapper)); + this.register(addDisposableDomListener(this.element, 'paste', pasteHandlerWrapper)); // Handle right click context menus if (Browser.isFirefox) { // Firefox doesn't appear to fire the contextmenu event on right click - on(this.element, 'mousedown', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'mousedown', (event: MouseEvent) => { if (event.button === 2) { rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord); } - }); + })); } else { - on(this.element, 'contextmenu', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'contextmenu', (event: MouseEvent) => { rightClickHandler(event, this.textarea, this.selectionManager, this.options.rightClickSelectsWord); - }); + })); } // Move the textarea under the cursor when middle clicking on Linux to ensure @@ -585,11 +556,11 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (Browser.isLinux) { // Use auxclick event over mousedown the latter doesn't seem to work. Note // that the regular click event doesn't fire for the middle mouse button. - on(this.element, 'auxclick', (event: MouseEvent) => { + this.register(addDisposableDomListener(this.element, 'auxclick', (event: MouseEvent) => { if (event.button === 1) { moveTextAreaUnderMouseCursor(event, this.textarea); } - }); + })); } } @@ -598,33 +569,35 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II */ private _bindKeys(): void { const self = this; - on(this.element, 'keydown', function (ev: KeyboardEvent): void { + this.register(addDisposableDomListener(this.element, 'keydown', function (ev: KeyboardEvent): void { if (document.activeElement !== this) { return; } self._keyDown(ev); - }, true); + }, true)); - on(this.element, 'keypress', function (ev: KeyboardEvent): void { + this.register(addDisposableDomListener(this.element, 'keypress', function (ev: KeyboardEvent): void { if (document.activeElement !== this) { return; } self._keyPress(ev); - }, true); + }, true)); - on(this.element, 'keyup', (ev: KeyboardEvent) => { + this.register(addDisposableDomListener(this.element, 'keyup', (ev: KeyboardEvent) => { if (!wasMondifierKeyOnlyEvent(ev)) { this.focus(); } - }, true); - on(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true); - on(this.textarea, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true); - on(this.textarea, 'compositionstart', () => this._compositionHelper.compositionstart()); - on(this.textarea, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper.compositionupdate(e)); - on(this.textarea, 'compositionend', () => this._compositionHelper.compositionend()); - this.on('refresh', () => this._compositionHelper.updateCompositionElements()); - this.on('refresh', (data) => this._queueLinkification(data.start, data.end)); + self._keyUp(ev); + }, true)); + + this.register(addDisposableDomListener(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true)); + this.register(addDisposableDomListener(this.textarea, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true)); + this.register(addDisposableDomListener(this.textarea, 'compositionstart', () => this._compositionHelper.compositionstart())); + this.register(addDisposableDomListener(this.textarea, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper.compositionupdate(e))); + this.register(addDisposableDomListener(this.textarea, 'compositionend', () => this._compositionHelper.compositionend())); + this.register(this.addDisposableListener('refresh', () => this._compositionHelper.updateCompositionElements())); + this.register(this.addDisposableListener('refresh', (data) => this._queueLinkification(data.start, data.end))); } /** @@ -645,6 +618,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this._screenDprMonitor = new ScreenDprMonitor(); this._screenDprMonitor.setListener(() => this.emit('dprchange', window.devicePixelRatio)); + this.register(this._screenDprMonitor); // Create main element container this.element = this._document.createElement('div'); @@ -674,7 +648,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II fragment.appendChild(this.screenElement); this._mouseZoneManager = new MouseZoneManager(this); - this.on('scroll', () => this._mouseZoneManager.clearAll()); + this.register(this._mouseZoneManager); + this.register(this.addDisposableListener('scroll', () => this._mouseZoneManager.clearAll())); this.linkifier.attachToDom(this._mouseZoneManager); this.textarea = document.createElement('textarea'); @@ -686,8 +661,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); this.textarea.tabIndex = 0; - this.textarea.addEventListener('focus', () => this._onTextAreaFocus()); - this.textarea.addEventListener('blur', () => this._onTextAreaBlur()); + this.register(addDisposableDomListener(this.textarea, 'focus', () => this._onTextAreaFocus())); + this.register(addDisposableDomListener(this.textarea, 'blur', () => this._onTextAreaBlur())); this._helperContainer.appendChild(this.textarea); this._compositionView = document.createElement('div'); @@ -700,38 +675,44 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // Performance: Add viewport and helper elements from the fragment this.element.appendChild(fragment); - this.renderer = new Renderer(this, this.options.theme); + switch (this.options.rendererType) { + case 'canvas': this.renderer = new Renderer(this, this.options.theme); break; + case 'dom': this.renderer = new DomRenderer(this, this.options.theme); break; + default: throw new Error(`Unrecognized rendererType "${this.options.rendererType}"`); + } + this.register(this.renderer); this.options.theme = null; this.viewport = new Viewport(this, this._viewportElement, this._viewportScrollArea, this.charMeasure); this.viewport.onThemeChanged(this.renderer.colorManager.colors); + this.register(this.viewport); - this.on('cursormove', () => this.renderer.onCursorMove()); - this.on('resize', () => this.renderer.onResize(this.cols, this.rows)); - this.on('blur', () => this.renderer.onBlur()); - this.on('focus', () => this.renderer.onFocus()); - this.on('dprchange', () => this.renderer.onWindowResize(window.devicePixelRatio)); + this.register(this.addDisposableListener('cursormove', () => this.renderer.onCursorMove())); + this.register(this.addDisposableListener('resize', () => this.renderer.onResize(this.cols, this.rows))); + this.register(this.addDisposableListener('blur', () => this.renderer.onBlur())); + this.register(this.addDisposableListener('focus', () => this.renderer.onFocus())); + this.register(this.addDisposableListener('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. - 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()); + this.register(addDisposableDomListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); + this.register(this.charMeasure.addDisposableListener('charsizechanged', () => this.renderer.onCharSizeChanged())); + this.register(this.renderer.addDisposableListener('resize', (dimensions) => this.viewport.syncScrollArea())); this.selectionManager = new SelectionManager(this, this.charMeasure); - this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e)); - this.selectionManager.on('refresh', data => this.renderer.onSelectionChanged(data.start, data.end)); - this.selectionManager.on('newselection', text => { + this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); + this.register(this.selectionManager.addDisposableListener('refresh', data => this.renderer.onSelectionChanged(data.start, data.end, data.columnSelectMode))); + this.register(this.selectionManager.addDisposableListener('newselection', text => { // If there's a new selection, put it into the textarea, focus and select it // in order to register it as a selection on the OS. This event is fired // only on Linux to enable middle click to paste selection. this.textarea.value = text; this.textarea.focus(); this.textarea.select(); - }); - this.on('scroll', () => { + })); + this.register(this.addDisposableListener('scroll', () => { this.viewport.syncScrollArea(); this.selectionManager.refresh(); - }); - this._viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); + })); + this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this.selectionManager.refresh())); this.mouseHelper = new MouseHelper(this.renderer); @@ -767,14 +748,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } - /** - * Apply the provided addon on the `Terminal` class. - * @param addon The addon to apply. - */ - public static applyAddon(addon: any): void { - addon.apply(Terminal); - } - /** * XTerm mouse events * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking @@ -1009,7 +982,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return button; } - on(el, 'mousedown', (ev: MouseEvent) => { + this.register(addDisposableDomListener(el, 'mousedown', (ev: MouseEvent) => { // Prevent the focus on the textarea from getting lost // and make sure we get focused on mousedown @@ -1034,30 +1007,37 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return this.cancel(ev); } + // TODO: these event listeners should be managed by the disposable, the Terminal reference may + // be kept aroud if Terminal.dispose is fired when the mouse is down + // bind events - if (this.normalMouse) on(this._document, 'mousemove', sendMove); + if (this.normalMouse) { + this._document.addEventListener('mousemove', sendMove); + } // x10 compatibility mode can't send button releases if (!this.x10Mouse) { const handler = (ev: MouseEvent) => { sendButton(ev); // TODO: Seems dangerous calling this on document? - if (this.normalMouse) off(this._document, 'mousemove', sendMove); - off(this._document, 'mouseup', handler); + if (this.normalMouse) { + this._document.removeEventListener('mousemove', sendMove); + } + this._document.removeEventListener('mouseup', handler); return this.cancel(ev); }; // TODO: Seems dangerous calling this on document? - on(this._document, 'mouseup', handler); + this._document.addEventListener('mouseup', handler); } return this.cancel(ev); - }); + })); // if (this.normalMouse) { // on(this.document, 'mousemove', sendMove); // } - on(el, 'wheel', (ev: WheelEvent) => { + this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { if (!this.mouseEvents) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux. @@ -1082,27 +1062,27 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (this.x10Mouse || this._vt300Mouse || this._decLocator) return; sendButton(ev); ev.preventDefault(); - }); + })); // allow wheel scrolling in // the shell for example - on(el, 'wheel', (ev: WheelEvent) => { + this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { if (this.mouseEvents) return; this.viewport.onWheel(ev); return this.cancel(ev); - }); + })); - on(el, 'touchstart', (ev: TouchEvent) => { + this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { if (this.mouseEvents) return; this.viewport.onTouchStart(ev); return this.cancel(ev); - }); + })); - on(el, 'touchmove', (ev: TouchEvent) => { + this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { if (this.mouseEvents) return; this.viewport.onTouchMove(ev); return this.cancel(ev); - }); + })); } /** @@ -1128,6 +1108,17 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } + /** + * Change the cursor style for different selection modes + */ + public updateCursorStyle(ev: KeyboardEvent): void { + if (this.selectionManager && this.selectionManager.shouldColumnSelect(ev)) { + this.element.classList.add('xterm-cursor-crosshair'); + } else { + this.element.classList.remove('xterm-cursor-crosshair'); + } + } + /** * Display the cursor element */ @@ -1292,7 +1283,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } - private _innerWrite(): void { + protected _innerWrite(): void { const writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE); while (writeBatch.length > 0) { const data = writeBatch.shift(); @@ -1443,19 +1434,21 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent * @param {KeyboardEvent} ev The keydown event to be handled. */ - protected _keyDown(ev: KeyboardEvent): boolean { - if (this._customKeyEventHandler && this._customKeyEventHandler(ev) === false) { + protected _keyDown(event: KeyboardEvent): boolean { + if (this._customKeyEventHandler && this._customKeyEventHandler(event) === false) { return false; } - if (!this._compositionHelper.keydown(ev)) { + if (!this._compositionHelper.keydown(event)) { if (this.buffer.ybase !== this.buffer.ydisp) { this.scrollToBottom(); } return false; } - const result = this._evaluateKeyEscapeSequence(ev); + const result = evaluateKeyboardEvent(event, this.applicationCursor, this.browser.isMac, this.options.macOptionIsMeta); + + this.updateCursorStyle(event); // if (result.key === C0.DC3) { // XOFF // this._writeStopped = true; @@ -1463,33 +1456,38 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // this._writeStopped = false; // } - if (result.scrollLines) { - this.scrollLines(result.scrollLines); - return this.cancel(ev, true); + if (result.type === KeyboardResultType.PAGE_DOWN || result.type === KeyboardResultType.PAGE_UP) { + const scrollCount = this.rows - 1; + this.scrollLines(result.type === KeyboardResultType.PAGE_UP ? -scrollCount : scrollCount); + return this.cancel(event, true); } - if (this._isThirdLevelShift(this.browser, ev)) { + if (result.type === KeyboardResultType.SELECT_ALL) { + this.selectAll(); + } + + if (this._isThirdLevelShift(this.browser, event)) { return true; } if (result.cancel) { // The event is canceled at the end already, is this necessary? - this.cancel(ev, true); + this.cancel(event, true); } if (!result.key) { return true; } - this.emit('keydown', ev); - this.emit('key', result.key, ev); + this.emit('keydown', event); + this.emit('key', result.key, event); this.showCursor(); this.handler(result.key); - return this.cancel(ev, true); + return this.cancel(event, true); } - private _isThirdLevelShift(browser: IBrowser, ev: KeyboardEvent): boolean { + private _isThirdLevelShift(browser: IBrowser, ev: IKeyboardEvent): boolean { const thirdLevelKey = (browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) || (browser.isMSWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); @@ -1502,329 +1500,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47); } - /** - * Returns an object that determines how a KeyboardEvent should be handled. The key of the - * returned value is the new key code to pass to the PTY. - * - * Reference: http://invisible-island.net/xterm/ctlseqs/ctlseqs.html - * @param ev The keyboard event to be translated to key escape sequence. - */ - protected _evaluateKeyEscapeSequence(ev: KeyboardEvent): {cancel: boolean, key: string, scrollLines: number} { - const result: {cancel: boolean, key: string, scrollLines: number} = { - // Whether to cancel event propogation (NOTE: this may not be needed since the event is - // canceled at the end of keyDown - cancel: false, - // The new key even to emit - key: undefined, - // The number of characters to scroll, if this is defined it will cancel the event - scrollLines: undefined - }; - const modifiers = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0) | (ev.metaKey ? 8 : 0); - switch (ev.keyCode) { - case 0: - if (ev.key === 'UIKeyInputUpArrow') { - if (this.applicationCursor) { - result.key = C0.ESC + 'OA'; - } else { - result.key = C0.ESC + '[A'; - } - } - else if (ev.key === 'UIKeyInputLeftArrow') { - if (this.applicationCursor) { - result.key = C0.ESC + 'OD'; - } else { - result.key = C0.ESC + '[D'; - } - } - else if (ev.key === 'UIKeyInputRightArrow') { - if (this.applicationCursor) { - result.key = C0.ESC + 'OC'; - } else { - result.key = C0.ESC + '[C'; - } - } - else if (ev.key === 'UIKeyInputDownArrow') { - if (this.applicationCursor) { - result.key = C0.ESC + 'OB'; - } else { - result.key = C0.ESC + '[B'; - } - } - break; - case 8: - // backspace - if (ev.shiftKey) { - result.key = C0.BS; // ^H - break; - } else if (ev.altKey) { - result.key = C0.ESC + C0.DEL; // \e ^? - break; - } - result.key = C0.DEL; // ^? - break; - case 9: - // tab - if (ev.shiftKey) { - result.key = C0.ESC + '[Z'; - break; - } - result.key = C0.HT; - result.cancel = true; - break; - case 13: - // return/enter - result.key = C0.CR; - result.cancel = true; - break; - case 27: - // escape - result.key = C0.ESC; - result.cancel = true; - break; - case 37: - // left-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'D'; - // HACK: Make Alt + left-arrow behave like Ctrl + left-arrow: move one word backwards - // http://unix.stackexchange.com/a/108106 - // macOS uses different escape sequences than linux - if (result.key === C0.ESC + '[1;3D') { - result.key = (this.browser.isMac) ? C0.ESC + 'b' : C0.ESC + '[1;5D'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OD'; - } else { - result.key = C0.ESC + '[D'; - } - break; - case 39: - // right-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'C'; - // HACK: Make Alt + right-arrow behave like Ctrl + right-arrow: move one word forward - // http://unix.stackexchange.com/a/108106 - // macOS uses different escape sequences than linux - if (result.key === C0.ESC + '[1;3C') { - result.key = (this.browser.isMac) ? C0.ESC + 'f' : C0.ESC + '[1;5C'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OC'; - } else { - result.key = C0.ESC + '[C'; - } - break; - case 38: - // up-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'A'; - // HACK: Make Alt + up-arrow behave like Ctrl + up-arrow - // http://unix.stackexchange.com/a/108106 - if (result.key === C0.ESC + '[1;3A') { - result.key = C0.ESC + '[1;5A'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OA'; - } else { - result.key = C0.ESC + '[A'; - } - break; - case 40: - // down-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'B'; - // HACK: Make Alt + down-arrow behave like Ctrl + down-arrow - // http://unix.stackexchange.com/a/108106 - if (result.key === C0.ESC + '[1;3B') { - result.key = C0.ESC + '[1;5B'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OB'; - } else { - result.key = C0.ESC + '[B'; - } - break; - case 45: - // insert - if (!ev.shiftKey && !ev.ctrlKey) { - // or + are used to - // copy-paste on some systems. - result.key = C0.ESC + '[2~'; - } - break; - case 46: - // delete - if (modifiers) { - result.key = C0.ESC + '[3;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[3~'; - } - break; - case 36: - // home - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'H'; - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OH'; - } else { - result.key = C0.ESC + '[H'; - } - break; - case 35: - // end - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'F'; - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OF'; - } else { - result.key = C0.ESC + '[F'; - } - break; - case 33: - // page up - if (ev.shiftKey) { - result.scrollLines = -(this.rows - 1); - } else { - result.key = C0.ESC + '[5~'; - } - break; - case 34: - // page down - if (ev.shiftKey) { - result.scrollLines = this.rows - 1; - } else { - result.key = C0.ESC + '[6~'; - } - break; - case 112: - // F1-F12 - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'P'; - } else { - result.key = C0.ESC + 'OP'; - } - break; - case 113: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'Q'; - } else { - result.key = C0.ESC + 'OQ'; - } - break; - case 114: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'R'; - } else { - result.key = C0.ESC + 'OR'; - } - break; - case 115: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'S'; - } else { - result.key = C0.ESC + 'OS'; - } - break; - case 116: - if (modifiers) { - result.key = C0.ESC + '[15;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[15~'; - } - break; - case 117: - if (modifiers) { - result.key = C0.ESC + '[17;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[17~'; - } - break; - case 118: - if (modifiers) { - result.key = C0.ESC + '[18;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[18~'; - } - break; - case 119: - if (modifiers) { - result.key = C0.ESC + '[19;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[19~'; - } - break; - case 120: - if (modifiers) { - result.key = C0.ESC + '[20;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[20~'; - } - break; - case 121: - if (modifiers) { - result.key = C0.ESC + '[21;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[21~'; - } - break; - case 122: - if (modifiers) { - result.key = C0.ESC + '[23;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[23~'; - } - break; - case 123: - if (modifiers) { - result.key = C0.ESC + '[24;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[24~'; - } - break; - default: - // a-z and space - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - if (ev.keyCode >= 65 && ev.keyCode <= 90) { - result.key = String.fromCharCode(ev.keyCode - 64); - } else if (ev.keyCode === 32) { - // NUL - result.key = String.fromCharCode(0); - } else if (ev.keyCode >= 51 && ev.keyCode <= 55) { - // escape, file sep, group sep, record sep, unit sep - result.key = String.fromCharCode(ev.keyCode - 51 + 27); - } else if (ev.keyCode === 56) { - // delete - result.key = String.fromCharCode(127); - } else if (ev.keyCode === 219) { - // ^[ - Control Sequence Introducer (CSI) - result.key = String.fromCharCode(27); - } else if (ev.keyCode === 220) { - // ^\ - String Terminator (ST) - result.key = String.fromCharCode(28); - } else if (ev.keyCode === 221) { - // ^] - Operating System Command (OSC) - result.key = String.fromCharCode(29); - } - } else if ((!this.browser.isMac || this.options.macOptionIsMeta) && ev.altKey && !ev.metaKey) { - // On macOS this is a third level shift when !macOptionIsMeta. Use instead. - const keyMapping = KEYCODE_KEY_MAPPINGS[ev.keyCode]; - const key = keyMapping && keyMapping[!ev.shiftKey ? 0 : 1]; - if (key) { - result.key = C0.ESC + key; - } else if (ev.keyCode >= 65 && ev.keyCode <= 90) { - const keyCode = ev.ctrlKey ? ev.keyCode - 64 : ev.keyCode + 32; - result.key = C0.ESC + String.fromCharCode(keyCode); - } - } else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) { - if (ev.keyCode === 65) { // cmd + a - this.selectAll(); - } - } - break; - } - - return result; - } - /** * Set the G level of the terminal * @param g @@ -1846,6 +1521,10 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } + protected _keyUp(ev: KeyboardEvent): void { + this.updateCursorStyle(ev); + } + /** * Handle a keypress event. * Key Resources: @@ -2069,7 +1748,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * set, the terminal's current column count would be used. */ public blankLine(cur?: boolean, isWrapped?: boolean, cols?: number): LineData { - const attr = cur ? this.eraseAttr() : this.defAttr; + const attr = cur ? this.eraseAttr() : DEFAULT_ATTR; const ch: CharData = [attr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; // width defaults to 1 halfwidth character const line: LineData = []; @@ -2089,14 +1768,14 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } /** - * If cur return the back color xterm feature attribute. Else return defAttr. + * If cur return the back color xterm feature attribute. Else return default attribute. * @param cur */ public ch(cur?: boolean): CharData { if (cur) { return [this.eraseAttr(), ' ', 1, 32 /* ' '.charCodeAt(0) */]; } - return [this.defAttr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; + return [DEFAULT_ATTR, ' ', 1, 32 /* ' '.charCodeAt(0) */]; } /** @@ -2190,9 +1869,11 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.options.cols = this.cols; const customKeyEventHandler = this._customKeyEventHandler; const inputHandler = this._inputHandler; + const cursorState = this.cursorState; this._setup(); this._customKeyEventHandler = customKeyEventHandler; this._inputHandler = inputHandler; + this.cursorState = cursorState; this.refresh(0, this.rows - 1); if (this.viewport) { this.viewport.syncScrollArea(); @@ -2219,7 +1900,42 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // TODO: Remove when true color is implemented public matchColor(r1: number, g1: number, b1: number): number { - return matchColor_(r1, g1, b1); + const hash = (r1 << 16) | (g1 << 8) | b1; + + if (matchColorCache[hash] != null) { + return matchColorCache[hash]; + } + + let ldiff = Infinity; + let li = -1; + let i = 0; + let c: number; + let r2: number; + let g2: number; + let b2: number; + let diff: number; + + for (; i < DEFAULT_ANSI_COLORS.length; i++) { + c = DEFAULT_ANSI_COLORS[i].rgba; + r2 = c >>> 24; + g2 = c >>> 16 & 0xFF; + b2 = c >>> 8 & 0xFF; + // assume that alpha is 0xFF + + diff = matchColorDistance(r1, g1, b1, r2, g2, b2); + + if (diff === 0) { + li = i; + break; + } + + if (diff < ldiff) { + ldiff = diff; + li = i; + } + } + + return matchColorCache[hash] = li; } private _visualBell(): boolean { @@ -2239,21 +1955,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * Helpers */ -function globalOn(el: any, type: string, handler: (event: Event) => any, capture?: boolean): void { - if (!Array.isArray(el)) { - el = [el]; - } - el.forEach((element: HTMLElement) => { - element.addEventListener(type, handler, capture || false); - }); -} -// TODO: Remove once everything is typed -const on = globalOn; - -function off(el: any, type: string, handler: (event: Event) => any, capture: boolean = false): void { - el.removeEventListener(type, handler, capture); -} - function wasMondifierKeyOnlyEvent(ev: KeyboardEvent): boolean { return ev.keyCode === 16 || // Shift ev.keyCode === 17 || // Ctrl @@ -2275,43 +1976,3 @@ function matchColorDistance(r1: number, g1: number, b1: number, r2: number, g2: + Math.pow(59 * (g1 - g2), 2) + Math.pow(11 * (b1 - b2), 2); } - - -function matchColor_(r1: number, g1: number, b1: number): number { - const hash = (r1 << 16) | (g1 << 8) | b1; - - if (matchColorCache[hash] != null) { - return matchColorCache[hash]; - } - - let ldiff = Infinity; - let li = -1; - let i = 0; - let c: number; - let r2: number; - let g2: number; - let b2: number; - let diff: number; - - for (; i < DEFAULT_ANSI_COLORS.length; i++) { - c = DEFAULT_ANSI_COLORS[i].rgba; - r2 = c >>> 24; - g2 = c >>> 16 & 0xFF; - b2 = c >>> 8 & 0xFF; - // assume that alpha is 0xFF - - diff = matchColorDistance(r1, g1, b1, r2, g2, b2); - - if (diff === 0) { - li = i; - break; - } - - if (diff < ldiff) { - ldiff = diff; - li = i; - } - } - - return matchColorCache[hash] = li; -} diff --git a/src/Types.ts b/src/Types.ts index f7490a99..26a8a861 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -3,9 +3,10 @@ * @license MIT */ -import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter } from 'xterm'; +import { Terminal as PublicTerminal, ITerminalOptions as IPublicTerminalOptions, IEventEmitter, IDisposable } from 'xterm'; import { IColorSet, IRenderer } from './renderer/Types'; -import { IMouseZoneManager } from './input/Types'; +import { IMouseZoneManager } from './ui/Types'; +import { ICharset } from './core/Types'; export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; @@ -45,7 +46,6 @@ export interface IInputHandlingTerminal extends IEventEmitter { insertMode: boolean; wraparoundMode: boolean; bracketedPasteMode: boolean; - defAttr: number; curAttr: number; savedCols: number; x10Mouse: boolean; @@ -88,7 +88,7 @@ export interface IInputHandlingTerminal extends IEventEmitter { tabSet(): void; } -export interface IViewport { +export interface IViewport extends IDisposable { scrollBarWidth: number; syncScrollArea(): void; getLinesScrolled(ev: WheelEvent): number; @@ -141,12 +141,12 @@ export interface IInputHandler { /** CSI X */ eraseChars(params?: number[]): void; /** CSI Z */ cursorBackwardTab(params?: number[]): void; /** CSI ` */ charPosAbsolute(params?: number[]): void; - /** CSI a */ HPositionRelative(params?: number[]): void; + /** CSI a */ hPositionRelative(params?: number[]): void; /** CSI b */ repeatPrecedingCharacter(params?: number[]): void; /** CSI c */ sendDeviceAttributes(params?: number[], collect?: string): void; /** CSI d */ linePosAbsolute(params?: number[]): void; - /** CSI e */ VPositionRelative(params?: number[]): void; - /** CSI f */ HVPosition(params?: number[]): void; + /** CSI e */ vPositionRelative(params?: number[]): void; + /** CSI f */ hVPosition(params?: number[]): void; /** CSI g */ tabClear(params?: number[]): void; /** CSI h */ setMode(params?: number[], collect?: string): void; /** CSI l */ resetMode(params?: number[], collect?: string): void; @@ -194,10 +194,6 @@ export interface ILinkMatcher { willLinkActivate?: (event: MouseEvent, uri: string) => boolean; } -export interface ICharset { - [key: string]: string; -} - export interface ILinkHoverEvent { x1: number; y1: number; @@ -215,7 +211,6 @@ export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAcce writeBuffer: string[]; cursorHidden: boolean; cursorState: number; - defAttr: number; options: ITerminalOptions; buffer: IBuffer; buffers: IBufferSet; @@ -263,6 +258,7 @@ export interface ICharMeasure { // TODO: The options that are not in the public API should be reviewed export interface ITerminalOptions extends IPublicTerminalOptions { + [key: string]: any; cancelEvents?: boolean; convertEol?: boolean; debug?: boolean; @@ -286,6 +282,7 @@ export interface IBuffer { savedX: number; isCursorInViewport: boolean; translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string; + getWrappedRangeForLine(y: number): { first: number, last: number }; nextStop(x?: number): number; prevStop(x?: number): number; } @@ -473,7 +470,7 @@ export interface IDcsHandler { /** * EscapeSequenceParser interface. */ -export interface IEscapeSequenceParser { +export interface IEscapeSequenceParser extends IDisposable { /** * Reset the parser to its initial state (handlers are kept). */ diff --git a/src/Viewport.ts b/src/Viewport.ts index f690c348..a8966d14 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -5,7 +5,9 @@ import { IColorSet } from './renderer/Types'; import { ITerminal, IViewport } from './Types'; -import { CharMeasure } from './utils/CharMeasure'; +import { CharMeasure } from './ui/CharMeasure'; +import { Disposable } from './common/Lifecycle'; +import { addDisposableDomListener } from './ui/Lifecycle'; const FALLBACK_SCROLL_BAR_WIDTH = 15; @@ -13,7 +15,7 @@ const FALLBACK_SCROLL_BAR_WIDTH = 15; * Represents the viewport of a terminal, the visible area within the larger buffer of output. * Logic for the virtual scroll bar is included in this object. */ -export class Viewport implements IViewport { +export class Viewport extends Disposable implements IViewport { public scrollBarWidth: number = 0; private _currentRowHeight: number = 0; private _lastRecordedBufferLength: number = 0; @@ -39,11 +41,13 @@ export class Viewport implements IViewport { private _scrollArea: HTMLElement, private _charMeasure: CharMeasure ) { + super(); + // Measure the width of the scrollbar. If it is 0 we can assume it's an OSX overlay scrollbar. // Unfortunately the overlay scrollbar would be hidden underneath the screen element in that case, // therefore we account for a standard amount to make it visible this.scrollBarWidth = (this._viewportElement.offsetWidth - this._scrollArea.offsetWidth) || FALLBACK_SCROLL_BAR_WIDTH; - this._viewportElement.addEventListener('scroll', this._onScroll.bind(this)); + this.register(addDisposableDomListener(this._viewportElement, 'scroll', this._onScroll.bind(this))); // Perform this async to ensure the CharMeasure is ready. setTimeout(() => this.syncScrollArea(), 0); diff --git a/src/addons/attach/Interfaces.ts b/src/addons/attach/Interfaces.ts index ab809af9..ab5846f5 100644 --- a/src/addons/attach/Interfaces.ts +++ b/src/addons/attach/Interfaces.ts @@ -5,9 +5,13 @@ * Implements the attach method, that attaches the terminal to a WebSocket stream. */ -import { Terminal } from 'xterm'; +import { Terminal, IDisposable } from 'xterm'; export interface IAttachAddonTerminal extends Terminal { + _core: { + register(d: T): void; + }; + __socket?: WebSocket; __attachSocketBuffer?: string; diff --git a/src/addons/attach/attach.ts b/src/addons/attach/attach.ts index d0e8bd4b..98a0bfaa 100644 --- a/src/addons/attach/attach.ts +++ b/src/addons/attach/attach.ts @@ -5,7 +5,7 @@ * Implements the attach method, that attaches the terminal to a WebSocket stream. */ -import { Terminal } from 'xterm'; +import { Terminal, IDisposable } from 'xterm'; import { IAttachAddonTerminal } from './Interfaces'; /** @@ -36,10 +36,11 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean } }; - let myTextDecoder; + // TODO: This should be typed but there seem to be issues importing the type + let myTextDecoder: any; addonTerminal.__getMessage = function(ev: MessageEvent): void { - let str; + let str: string; if (typeof ev.data === 'object') { if (!myTextDecoder) { @@ -86,14 +87,28 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean socket.send(data); }; - socket.addEventListener('message', addonTerminal.__getMessage); + addonTerminal._core.register(addSocketListener(socket, 'message', addonTerminal.__getMessage)); if (bidirectional) { - addonTerminal.on('data', addonTerminal.__sendData); + addonTerminal._core.register(addonTerminal.addDisposableListener('data', addonTerminal.__sendData)); } - socket.addEventListener('close', () => detach(addonTerminal, socket)); - socket.addEventListener('error', () => detach(addonTerminal, socket)); + addonTerminal._core.register(addSocketListener(socket, 'close', () => detach(addonTerminal, socket))); + addonTerminal._core.register(addSocketListener(socket, 'error', () => detach(addonTerminal, socket))); +} + +function addSocketListener(socket: WebSocket, type: string, handler: (this: WebSocket, ev: Event) => any): IDisposable { + socket.addEventListener(type, handler); + return { + dispose: () => { + if (!handler) { + // Already disposed + return; + } + socket.removeEventListener(type, handler); + handler = null; + } + }; } /** diff --git a/src/addons/attach/tsconfig.json b/src/addons/attach/tsconfig.json index 19954249..12e75810 100644 --- a/src/addons/attach/tsconfig.json +++ b/src/addons/attach/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/attach/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/fit/README.md b/src/addons/fit/README.md new file mode 100644 index 00000000..68f01589 --- /dev/null +++ b/src/addons/fit/README.md @@ -0,0 +1,3 @@ +## fit addon + +The fit addon adjusts the dimensions of the terminal to match best fit its parent element container. `fit` will only work when the element is visible. diff --git a/src/addons/fit/fit.ts b/src/addons/fit/fit.ts index 68e4cfd5..ac978807 100644 --- a/src/addons/fit/fit.ts +++ b/src/addons/fit/fit.ts @@ -37,10 +37,10 @@ export function proposeGeometry(term: Terminal): IGeometry { const elementPaddingVer = elementPadding.top + elementPadding.bottom; const elementPaddingHor = elementPadding.right + elementPadding.left; const availableHeight = parentElementHeight - elementPaddingVer; - const availableWidth = parentElementWidth - elementPaddingHor - (term).viewport.scrollBarWidth; + const availableWidth = parentElementWidth - elementPaddingHor - (term)._core.viewport.scrollBarWidth; const geometry = { - cols: Math.floor(availableWidth / (term).renderer.dimensions.actualCellWidth), - rows: Math.floor(availableHeight / (term).renderer.dimensions.actualCellHeight) + cols: Math.floor(availableWidth / (term)._core.renderer.dimensions.actualCellWidth), + rows: Math.floor(availableHeight / (term)._core.renderer.dimensions.actualCellHeight) }; return geometry; } @@ -50,7 +50,7 @@ export function fit(term: Terminal): void { if (geometry) { // Force a full render if (term.rows !== geometry.rows || term.cols !== geometry.cols) { - (term).renderer.clear(); + (term)._core.renderer.clear(); term.resize(geometry.cols, geometry.rows); } } diff --git a/src/addons/fit/tsconfig.json b/src/addons/fit/tsconfig.json index e1dce37f..f806af18 100644 --- a/src/addons/fit/tsconfig.json +++ b/src/addons/fit/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/fit/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/fullscreen/tsconfig.json b/src/addons/fullscreen/tsconfig.json index 5a06c490..2fbb295f 100644 --- a/src/addons/fullscreen/tsconfig.json +++ b/src/addons/fullscreen/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/fullscreen/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/search/Interfaces.ts b/src/addons/search/Interfaces.ts index 6faa03e2..926e3f36 100644 --- a/src/addons/search/Interfaces.ts +++ b/src/addons/search/Interfaces.ts @@ -5,14 +5,17 @@ import { Terminal } from 'xterm'; -export interface ISearchAddonTerminal extends Terminal { - __searchHelper?: ISearchHelper; - - // TODO: Reuse ITerminal from core +// TODO: Don't rely on this private API +export interface ITerminalCore { buffer: any; selectionManager: any; } +export interface ISearchAddonTerminal extends Terminal { + __searchHelper?: ISearchHelper; + _core: ITerminalCore; +} + export interface ISearchHelper { findNext(term: string): boolean; findPrevious(term: string): boolean; diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 2abc3b38..5ba81aa0 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -35,14 +35,14 @@ export class SearchHelper implements ISearchHelper { let result: ISearchResult; - let startRow = this._terminal.buffer.ydisp; - if (this._terminal.selectionManager.selectionEnd) { + let startRow = this._terminal._core.buffer.ydisp; + if (this._terminal._core.selectionManager.selectionEnd) { // Start from the selection end if there is a selection - startRow = this._terminal.selectionManager.selectionEnd[1]; + startRow = this._terminal._core.selectionManager.selectionEnd[1]; } // Search from ydisp + 1 to end - for (let y = startRow + 1; y < this._terminal.buffer.ybase + this._terminal.rows; y++) { + for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) { result = this._findInLine(term, y); if (result) { break; @@ -76,10 +76,10 @@ export class SearchHelper implements ISearchHelper { let result: ISearchResult; - let startRow = this._terminal.buffer.ydisp; - if (this._terminal.selectionManager.selectionStart) { + let startRow = this._terminal._core.buffer.ydisp; + if (this._terminal._core.selectionManager.selectionStart) { // Start from the selection end if there is a selection - startRow = this._terminal.selectionManager.selectionStart[1]; + startRow = this._terminal._core.selectionManager.selectionStart[1]; } // Search from ydisp + 1 to end @@ -92,7 +92,7 @@ export class SearchHelper implements ISearchHelper { // Search from the top to the current ydisp if (!result) { - for (let y = this._terminal.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) { + for (let y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) { result = this._findInLine(term, y); if (result) { break; @@ -111,11 +111,11 @@ export class SearchHelper implements ISearchHelper { * @return The search result if it was found. */ private _findInLine(term: string, y: number): ISearchResult { - const lowerStringLine = this._terminal.buffer.translateBufferLineToString(y, true).toLowerCase(); + const lowerStringLine = this._terminal._core.buffer.translateBufferLineToString(y, true).toLowerCase(); const lowerTerm = term.toLowerCase(); let searchIndex = lowerStringLine.indexOf(lowerTerm); if (searchIndex >= 0) { - const line = this._terminal.buffer.lines.get(y); + const line = this._terminal._core.buffer.lines.get(y); for (let i = 0; i < searchIndex; i++) { const charData = line[i]; // Adjust the searchIndex to normalize emoji into single chars @@ -147,8 +147,8 @@ export class SearchHelper implements ISearchHelper { if (!result) { return false; } - this._terminal.selectionManager.setSelection(result.col, result.row, result.term.length); - this._terminal.scrollLines(result.row - this._terminal.buffer.ydisp); + this._terminal._core.selectionManager.setSelection(result.col, result.row, result.term.length); + this._terminal.scrollLines(result.row - this._terminal._core.buffer.ydisp); return true; } } diff --git a/src/addons/search/tsconfig.json b/src/addons/search/tsconfig.json index d833bb28..47876a00 100644 --- a/src/addons/search/tsconfig.json +++ b/src/addons/search/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/search/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/terminado/tsconfig.json b/src/addons/terminado/tsconfig.json index 2c795b99..b664a5e0 100644 --- a/src/addons/terminado/tsconfig.json +++ b/src/addons/terminado/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/terminado/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/webLinks/tsconfig.json b/src/addons/webLinks/tsconfig.json index b34f544d..7dd999f6 100644 --- a/src/addons/webLinks/tsconfig.json +++ b/src/addons/webLinks/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/webLinks/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/winptyCompat/Interfaces.ts b/src/addons/winptyCompat/Interfaces.ts index 8e02c64b..6217c860 100644 --- a/src/addons/winptyCompat/Interfaces.ts +++ b/src/addons/winptyCompat/Interfaces.ts @@ -5,6 +5,10 @@ import { Terminal } from 'xterm'; -export interface IWinptyCompatAddonTerminal extends Terminal { +export interface ITerminalCore { buffer: any; } + +export interface IWinptyCompatAddonTerminal extends Terminal { + _core: ITerminalCore; +} diff --git a/src/addons/winptyCompat/tsconfig.json b/src/addons/winptyCompat/tsconfig.json index fdca3e3d..ecad2563 100644 --- a/src/addons/winptyCompat/tsconfig.json +++ b/src/addons/winptyCompat/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/winptyCompat/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/winptyCompat/winptyCompat.ts b/src/addons/winptyCompat/winptyCompat.ts index 9cde62e0..84b21590 100644 --- a/src/addons/winptyCompat/winptyCompat.ts +++ b/src/addons/winptyCompat/winptyCompat.ts @@ -26,11 +26,11 @@ export function winptyCompatInit(terminal: Terminal): void { // Windows when text reaches the end of the terminal it's likely going to be // wrapped. addonTerminal.on('linefeed', () => { - const line = addonTerminal.buffer.lines.get(addonTerminal.buffer.ybase + addonTerminal.buffer.y - 1); + const line = addonTerminal._core.buffer.lines.get(addonTerminal._core.buffer.ybase + addonTerminal._core.buffer.y - 1); const lastChar = line[addonTerminal.cols - 1]; if (lastChar[3] !== 32 /* ' ' */) { - const nextLine = addonTerminal.buffer.lines.get(addonTerminal.buffer.ybase + addonTerminal.buffer.y); + const nextLine = addonTerminal._core.buffer.lines.get(addonTerminal._core.buffer.ybase + addonTerminal._core.buffer.y); (nextLine).isWrapped = true; } }); diff --git a/src/addons/zmodem/tsconfig.json b/src/addons/zmodem/tsconfig.json index f5693801..d4577108 100644 --- a/src/addons/zmodem/tsconfig.json +++ b/src/addons/zmodem/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "../../../lib/addons/zmodem/", "sourceMap": true, "removeComments": true, - "declaration": true + "declaration": true, + "preserveWatchOutput": true }, "include": [ "**/*.ts", diff --git a/src/addons/zmodem/zmodem.ts b/src/addons/zmodem/zmodem.ts index 4683538f..23204f0f 100644 --- a/src/addons/zmodem/zmodem.ts +++ b/src/addons/zmodem/zmodem.ts @@ -46,13 +46,13 @@ function zmodemAttach(ws: WebSocket, opts: IZmodemOptions = {}): void { let zsentry; - function _shouldWrite(): boolean { + function shouldWrite(): boolean { return !!zsentry.get_confirmed_session() || !opts.noTerminalWriteOutsideSession; } zsentry = new zmodem.Sentry({ to_terminal: (octets: ArrayLike) => { - if (_shouldWrite()) { + if (shouldWrite()) { term.write( String.fromCharCode.apply(String, octets) ); @@ -70,7 +70,7 @@ function zmodemAttach(ws: WebSocket, opts: IZmodemOptions = {}): void { // may be specific to xterm.js’s demo, ultimately we // should reject anything that isn’t binary. if (typeof evt.data === 'string') { - if (_shouldWrite()) { + if (shouldWrite()) { term.write(evt.data); } } diff --git a/src/utils/CircularList.test.ts b/src/common/CircularList.test.ts similarity index 100% rename from src/utils/CircularList.test.ts rename to src/common/CircularList.test.ts diff --git a/src/utils/CircularList.ts b/src/common/CircularList.ts similarity index 100% rename from src/utils/CircularList.ts rename to src/common/CircularList.ts diff --git a/src/common/Lifecycle.ts b/src/common/Lifecycle.ts new file mode 100644 index 00000000..b9cf1418 --- /dev/null +++ b/src/common/Lifecycle.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from 'xterm'; + +/** + * A base class that can be extended to provide convenience methods for managing the lifecycle of an + * object and its components. + */ +export abstract class Disposable implements IDisposable { + protected _disposables: IDisposable[] = []; + + constructor() { + } + + /** + * Disposes the object, triggering the `dispose` method on all registered IDisposables. + */ + public dispose(): void { + this._disposables.forEach(d => d.dispose()); + this._disposables.length = 0; + } + + /** + * Registers a disposable object. + * @param d The disposable to register. + */ + public register(d: T): void { + this._disposables.push(d); + } +} diff --git a/src/common/Types.ts b/src/common/Types.ts new file mode 100644 index 00000000..98cb296d --- /dev/null +++ b/src/common/Types.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * A keyboard event interface which does not depend on the DOM, KeyboardEvent implicitly extends + * this event. + */ +export interface IKeyboardEvent { + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + keyCode: number; + key: string; + type: string; +} diff --git a/src/EscapeSequences.ts b/src/common/data/EscapeSequences.ts similarity index 100% rename from src/EscapeSequences.ts rename to src/common/data/EscapeSequences.ts diff --git a/src/core/Types.ts b/src/core/Types.ts new file mode 100644 index 00000000..4001e448 --- /dev/null +++ b/src/core/Types.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export const enum KeyboardResultType { + SEND_KEY, + SELECT_ALL, + PAGE_UP, + PAGE_DOWN +} + +export interface IKeyboardResult { + type: KeyboardResultType; + cancel: boolean; + key: string | undefined; +} + +export interface ICharset { + [key: string]: string; +} diff --git a/src/Charsets.ts b/src/core/data/Charsets.ts similarity index 99% rename from src/Charsets.ts rename to src/core/data/Charsets.ts index fe0112ec..b49ee77f 100644 --- a/src/Charsets.ts +++ b/src/core/data/Charsets.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ICharset } from './Types'; +import { ICharset } from '../Types'; /** * The character sets supported by the terminal. These enable several languages diff --git a/src/core/input/Keyboard.test.ts b/src/core/input/Keyboard.test.ts new file mode 100644 index 00000000..1104fc61 --- /dev/null +++ b/src/core/input/Keyboard.test.ts @@ -0,0 +1,284 @@ + +import { assert } from 'chai'; +import { evaluateKeyboardEvent } from './Keyboard'; +import { IKeyboardResult } from '../Types'; + +/** + * A helper function for testing which allows passing in a partial event and defaults will be filled + * in on it. + */ +function testEvaluateKeyboardEvent(partialEvent: { + altKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; + keyCode?: number; + key?: string; + type?: string; +}, partialOptions: { + applicationCursorMode?: boolean; + isMac?: boolean; + macOptionIsMeta?: boolean; +} = {}): IKeyboardResult { + const event = { + altKey: partialEvent.altKey || false, + ctrlKey: partialEvent.ctrlKey || false, + shiftKey: partialEvent.shiftKey || false, + metaKey: partialEvent.metaKey || false, + keyCode: partialEvent.keyCode !== undefined ? partialEvent.keyCode : undefined, + key: partialEvent.key || '', + type: partialEvent.type || '' + }; + const options = { + applicationCursorMode: partialOptions.applicationCursorMode || false, + isMac: partialOptions.isMac || false, + macOptionIsMeta: partialOptions.macOptionIsMeta || false + }; + return evaluateKeyboardEvent(event, options.applicationCursorMode, options.isMac, options.macOptionIsMeta); +} + +describe('Keyboard', () => { + describe('evaluateKeyEscapeSequence', () => { + it('should return the correct escape sequence for unmodified keys', () => { + // Backspace + assert.equal(testEvaluateKeyboardEvent({ keyCode: 8 }).key, '\x7f'); // ^? + // Tab + assert.equal(testEvaluateKeyboardEvent({ keyCode: 9 }).key, '\t'); + // Return/enter + assert.equal(testEvaluateKeyboardEvent({ keyCode: 13 }).key, '\r'); // CR + // Escape + assert.equal(testEvaluateKeyboardEvent({ keyCode: 27 }).key, '\x1b'); + // Page up, page down + assert.equal(testEvaluateKeyboardEvent({ keyCode: 33 }).key, '\x1b[5~'); // CSI 5 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 34 }).key, '\x1b[6~'); // CSI 6 ~ + // End, Home + assert.equal(testEvaluateKeyboardEvent({ keyCode: 35 }).key, '\x1b[F'); // SS3 F + assert.equal(testEvaluateKeyboardEvent({ keyCode: 36 }).key, '\x1b[H'); // SS3 H + // Left, up, right, down arrows + assert.equal(testEvaluateKeyboardEvent({ keyCode: 37 }).key, '\x1b[D'); // CSI D + assert.equal(testEvaluateKeyboardEvent({ keyCode: 38 }).key, '\x1b[A'); // CSI A + assert.equal(testEvaluateKeyboardEvent({ keyCode: 39 }).key, '\x1b[C'); // CSI C + assert.equal(testEvaluateKeyboardEvent({ keyCode: 40 }).key, '\x1b[B'); // CSI B + // Insert + assert.equal(testEvaluateKeyboardEvent({ keyCode: 45 }).key, '\x1b[2~'); // CSI 2 ~ + // Delete + assert.equal(testEvaluateKeyboardEvent({ keyCode: 46 }).key, '\x1b[3~'); // CSI 3 ~ + // F1-F12 + assert.equal(testEvaluateKeyboardEvent({ keyCode: 112 }).key, '\x1bOP'); // SS3 P + assert.equal(testEvaluateKeyboardEvent({ keyCode: 113 }).key, '\x1bOQ'); // SS3 Q + assert.equal(testEvaluateKeyboardEvent({ keyCode: 114 }).key, '\x1bOR'); // SS3 R + assert.equal(testEvaluateKeyboardEvent({ keyCode: 115 }).key, '\x1bOS'); // SS3 S + assert.equal(testEvaluateKeyboardEvent({ keyCode: 116 }).key, '\x1b[15~'); // CSI 1 5 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 117 }).key, '\x1b[17~'); // CSI 1 7 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 118 }).key, '\x1b[18~'); // CSI 1 8 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 119 }).key, '\x1b[19~'); // CSI 1 9 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 120 }).key, '\x1b[20~'); // CSI 2 0 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 121 }).key, '\x1b[21~'); // CSI 2 1 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 122 }).key, '\x1b[23~'); // CSI 2 3 ~ + assert.equal(testEvaluateKeyboardEvent({ keyCode: 123 }).key, '\x1b[24~'); // CSI 2 4 ~ + }); + it('should return \\x1b[3;5~ for ctrl+delete', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 46 }).key, '\x1b[3;5~'); + }); + it('should return \\x1b[3;2~ for shift+delete', () => { + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 46 }).key, '\x1b[3;2~'); + }); + it('should return \\x1b[3;3~ for alt+delete', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 46 }).key, '\x1b[3;3~'); + }); + it('should return \\x1b[5D for ctrl+left', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 37 }).key, '\x1b[1;5D'); // CSI 5 D + }); + it('should return \\x1b[5C for ctrl+right', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 39 }).key, '\x1b[1;5C'); // CSI 5 C + }); + it('should return \\x1b[5A for ctrl+up', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 38 }).key, '\x1b[1;5A'); // CSI 5 A + }); + it('should return \\x1b[5B for ctrl+down', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 40 }).key, '\x1b[1;5B'); // CSI 5 B + }); + + describe('On non-macOS platforms', () => { + // Evalueate alt + arrow key movement, which is a feature of terminal emulators but not VT100 + // http://unix.stackexchange.com/a/108106 + it('should return \\x1b[5D for alt+left', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 37 }, { isMac: false }).key, '\x1b[1;5D'); // CSI 5 D + }); + it('should return \\x1b[5C for alt+right', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 39 }, { isMac: false }).key, '\x1b[1;5C'); // CSI 5 C + }); + it('should return \\x1ba for alt+a', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 65 }, { isMac: false }).key, '\x1ba'); + }); + }); + + describe('On macOS platforms', () => { + it('should return \\x1bb for alt+left', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 37 }, { isMac: true }).key, '\x1bb'); // CSI 5 D + }); + it('should return \\x1bf for alt+right', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 39 }, { isMac: true }).key, '\x1bf'); // CSI 5 C + }); + it('should return undefined for alt+a', () => { + assert.strictEqual(testEvaluateKeyboardEvent({ altKey: true, keyCode: 65 }, { isMac: true }).key, undefined), { isMac: true }; + }); + }); + + describe('with macOptionIsMeta', () => { + it('should return \\x1ba for alt+a', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 65 }, { isMac: true, macOptionIsMeta: true }).key, '\x1ba'); + }); + }); + + it('should return \\x1b[5A for alt+up', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 38 }).key, '\x1b[1;5A'); // CSI 5 A + }); + it('should return \\x1b[5B for alt+down', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 40 }).key, '\x1b[1;5B'); // CSI 5 B + }); + it('should return the correct escape sequence for modified F1-F12 keys', () => { + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 112 }).key, '\x1b[1;2P'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 113 }).key, '\x1b[1;2Q'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 114 }).key, '\x1b[1;2R'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 115 }).key, '\x1b[1;2S'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 116 }).key, '\x1b[15;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 117 }).key, '\x1b[17;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 118 }).key, '\x1b[18;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 119 }).key, '\x1b[19;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 120 }).key, '\x1b[20;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 121 }).key, '\x1b[21;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 122 }).key, '\x1b[23;2~'); + assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 123 }).key, '\x1b[24;2~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 112 }).key, '\x1b[1;3P'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 113 }).key, '\x1b[1;3Q'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 114 }).key, '\x1b[1;3R'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 115 }).key, '\x1b[1;3S'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 116 }).key, '\x1b[15;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 117 }).key, '\x1b[17;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 118 }).key, '\x1b[18;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 119 }).key, '\x1b[19;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 120 }).key, '\x1b[20;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 121 }).key, '\x1b[21;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 122 }).key, '\x1b[23;3~'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, keyCode: 123 }).key, '\x1b[24;3~'); + + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 112 }).key, '\x1b[1;5P'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 113 }).key, '\x1b[1;5Q'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 114 }).key, '\x1b[1;5R'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 115 }).key, '\x1b[1;5S'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 116 }).key, '\x1b[15;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 117 }).key, '\x1b[17;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 118 }).key, '\x1b[18;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 119 }).key, '\x1b[19;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 120 }).key, '\x1b[20;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 121 }).key, '\x1b[21;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 122 }).key, '\x1b[23;5~'); + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, keyCode: 123 }).key, '\x1b[24;5~'); + }); + + // Characters using ctrl+alt sequences + it('should return proper sequence for ctrl+alt+a', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, ctrlKey: true, keyCode: 65 }).key, '\x1b\x01'); + }); + + // Characters using alt sequences (numbers) + it('should return proper sequences for alt+0', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 48 }).key, '\x1b0'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 48 }).key, '\x1b)'); + }); + it('should return proper sequences for alt+1', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 49 }).key, '\x1b1'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 49 }).key, '\x1b!'); + }); + it('should return proper sequences for alt+2', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 50 }).key, '\x1b2'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 50 }).key, '\x1b@'); + }); + it('should return proper sequences for alt+3', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 51 }).key, '\x1b3'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 51 }).key, '\x1b#'); + }); + it('should return proper sequences for alt+4', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 52 }).key, '\x1b4'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 52 }).key, '\x1b$'); + }); + it('should return proper sequences for alt+5', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 53 }).key, '\x1b5'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 53 }).key, '\x1b%'); + }); + it('should return proper sequences for alt+6', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 54 }).key, '\x1b6'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 54 }).key, '\x1b^'); + }); + it('should return proper sequences for alt+7', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 55 }).key, '\x1b7'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 55 }).key, '\x1b&'); + }); + it('should return proper sequences for alt+8', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 56 }).key, '\x1b8'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 56 }).key, '\x1b*'); + }); + it('should return proper sequences for alt+9', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 57 }).key, '\x1b9'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 57 }).key, '\x1b('); + }); + + // Characters using alt sequences (special chars) + it('should return proper sequences for alt+;', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 186 }).key, '\x1b;'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 186 }).key, '\x1b:'); + }); + it('should return proper sequences for alt+=', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 187 }).key, '\x1b='); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 187 }).key, '\x1b+'); + }); + it('should return proper sequences for alt+,', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 188 }).key, '\x1b,'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 188 }).key, '\x1b<'); + }); + it('should return proper sequences for alt+-', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 189 }).key, '\x1b-'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 189 }).key, '\x1b_'); + }); + it('should return proper sequences for alt+.', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 190 }).key, '\x1b.'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 190 }).key, '\x1b>'); + }); + it('should return proper sequences for alt+/', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 191 }).key, '\x1b/'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 191 }).key, '\x1b?'); + }); + it('should return proper sequences for alt+~', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 192 }).key, '\x1b`'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 192 }).key, '\x1b~'); + }); + it('should return proper sequences for alt+[', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 219 }).key, '\x1b['); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 219 }).key, '\x1b{'); + }); + it('should return proper sequences for alt+\\', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 220 }).key, '\x1b\\'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 220 }).key, '\x1b|'); + }); + it('should return proper sequences for alt+]', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 221 }).key, '\x1b]'); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 221 }).key, '\x1b}'); + }); + it('should return proper sequences for alt+\'', () => { + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: false, keyCode: 222 }).key, '\x1b\''); + assert.equal(testEvaluateKeyboardEvent({ altKey: true, shiftKey: true, keyCode: 222 }).key, '\x1b"'); + }); + + it('should handle mobile arrow events', () => { + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputUpArrow' }).key, '\x1b[A'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputUpArrow' }, { applicationCursorMode: true }).key, '\x1bOA'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputLeftArrow' }).key, '\x1b[D'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputLeftArrow' }, { applicationCursorMode: true }).key, '\x1bOD'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputRightArrow' }).key, '\x1b[C'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputRightArrow' }, { applicationCursorMode: true }).key, '\x1bOC'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputDownArrow' }).key, '\x1b[B'); + assert.equal(testEvaluateKeyboardEvent({ keyCode: 0, key: 'UIKeyInputDownArrow' }, { applicationCursorMode: true }).key, '\x1bOB'); + }); + }); +}); diff --git a/src/core/input/Keyboard.ts b/src/core/input/Keyboard.ts new file mode 100644 index 00000000..8c6f3c59 --- /dev/null +++ b/src/core/input/Keyboard.ts @@ -0,0 +1,357 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * @license MIT + */ + +import { IKeyboardEvent } from '../../common/Types'; +import { IKeyboardResult, KeyboardResultType } from '../Types'; +import { C0 } from '../../common/data/EscapeSequences'; + +// reg + shift key mappings for digits and special chars +const KEYCODE_KEY_MAPPINGS: { [key: number]: [string, string]} = { + // digits 0-9 + 48: ['0', ')'], + 49: ['1', '!'], + 50: ['2', '@'], + 51: ['3', '#'], + 52: ['4', '$'], + 53: ['5', '%'], + 54: ['6', '^'], + 55: ['7', '&'], + 56: ['8', '*'], + 57: ['9', '('], + + // special chars + 186: [';', ':'], + 187: ['=', '+'], + 188: [',', '<'], + 189: ['-', '_'], + 190: ['.', '>'], + 191: ['/', '?'], + 192: ['`', '~'], + 219: ['[', '{'], + 220: ['\\', '|'], + 221: [']', '}'], + 222: ['\'', '"'] +}; + +export function evaluateKeyboardEvent( + ev: IKeyboardEvent, + applicationCursorMode: boolean, + isMac: boolean, + macOptionIsMeta: boolean +): IKeyboardResult { + const result: IKeyboardResult = { + type: KeyboardResultType.SEND_KEY, + // Whether to cancel event propogation (NOTE: this may not be needed since the event is + // canceled at the end of keyDown + cancel: false, + // The new key even to emit + key: undefined + }; + const modifiers = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0) | (ev.metaKey ? 8 : 0); + switch (ev.keyCode) { + case 0: + if (ev.key === 'UIKeyInputUpArrow') { + if (applicationCursorMode) { + result.key = C0.ESC + 'OA'; + } else { + result.key = C0.ESC + '[A'; + } + } + else if (ev.key === 'UIKeyInputLeftArrow') { + if (applicationCursorMode) { + result.key = C0.ESC + 'OD'; + } else { + result.key = C0.ESC + '[D'; + } + } + else if (ev.key === 'UIKeyInputRightArrow') { + if (applicationCursorMode) { + result.key = C0.ESC + 'OC'; + } else { + result.key = C0.ESC + '[C'; + } + } + else if (ev.key === 'UIKeyInputDownArrow') { + if (applicationCursorMode) { + result.key = C0.ESC + 'OB'; + } else { + result.key = C0.ESC + '[B'; + } + } + break; + case 8: + // backspace + if (ev.shiftKey) { + result.key = C0.BS; // ^H + break; + } else if (ev.altKey) { + result.key = C0.ESC + C0.DEL; // \e ^? + break; + } + result.key = C0.DEL; // ^? + break; + case 9: + // tab + if (ev.shiftKey) { + result.key = C0.ESC + '[Z'; + break; + } + result.key = C0.HT; + result.cancel = true; + break; + case 13: + // return/enter + result.key = C0.CR; + result.cancel = true; + break; + case 27: + // escape + result.key = C0.ESC; + result.cancel = true; + break; + case 37: + // left-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'D'; + // HACK: Make Alt + left-arrow behave like Ctrl + left-arrow: move one word backwards + // http://unix.stackexchange.com/a/108106 + // macOS uses different escape sequences than linux + if (result.key === C0.ESC + '[1;3D') { + result.key = isMac ? C0.ESC + 'b' : C0.ESC + '[1;5D'; + } + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OD'; + } else { + result.key = C0.ESC + '[D'; + } + break; + case 39: + // right-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'C'; + // HACK: Make Alt + right-arrow behave like Ctrl + right-arrow: move one word forward + // http://unix.stackexchange.com/a/108106 + // macOS uses different escape sequences than linux + if (result.key === C0.ESC + '[1;3C') { + result.key = isMac ? C0.ESC + 'f' : C0.ESC + '[1;5C'; + } + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OC'; + } else { + result.key = C0.ESC + '[C'; + } + break; + case 38: + // up-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'A'; + // HACK: Make Alt + up-arrow behave like Ctrl + up-arrow + // http://unix.stackexchange.com/a/108106 + if (result.key === C0.ESC + '[1;3A') { + result.key = C0.ESC + '[1;5A'; + } + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OA'; + } else { + result.key = C0.ESC + '[A'; + } + break; + case 40: + // down-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'B'; + // HACK: Make Alt + down-arrow behave like Ctrl + down-arrow + // http://unix.stackexchange.com/a/108106 + if (result.key === C0.ESC + '[1;3B') { + result.key = C0.ESC + '[1;5B'; + } + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OB'; + } else { + result.key = C0.ESC + '[B'; + } + break; + case 45: + // insert + if (!ev.shiftKey && !ev.ctrlKey) { + // or + are used to + // copy-paste on some systems. + result.key = C0.ESC + '[2~'; + } + break; + case 46: + // delete + if (modifiers) { + result.key = C0.ESC + '[3;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[3~'; + } + break; + case 36: + // home + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'H'; + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OH'; + } else { + result.key = C0.ESC + '[H'; + } + break; + case 35: + // end + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'F'; + } else if (applicationCursorMode) { + result.key = C0.ESC + 'OF'; + } else { + result.key = C0.ESC + '[F'; + } + break; + case 33: + // page up + if (ev.shiftKey) { + result.type = KeyboardResultType.PAGE_UP; + } else { + result.key = C0.ESC + '[5~'; + } + break; + case 34: + // page down + if (ev.shiftKey) { + result.type = KeyboardResultType.PAGE_DOWN; + } else { + result.key = C0.ESC + '[6~'; + } + break; + case 112: + // F1-F12 + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'P'; + } else { + result.key = C0.ESC + 'OP'; + } + break; + case 113: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'Q'; + } else { + result.key = C0.ESC + 'OQ'; + } + break; + case 114: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'R'; + } else { + result.key = C0.ESC + 'OR'; + } + break; + case 115: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'S'; + } else { + result.key = C0.ESC + 'OS'; + } + break; + case 116: + if (modifiers) { + result.key = C0.ESC + '[15;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[15~'; + } + break; + case 117: + if (modifiers) { + result.key = C0.ESC + '[17;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[17~'; + } + break; + case 118: + if (modifiers) { + result.key = C0.ESC + '[18;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[18~'; + } + break; + case 119: + if (modifiers) { + result.key = C0.ESC + '[19;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[19~'; + } + break; + case 120: + if (modifiers) { + result.key = C0.ESC + '[20;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[20~'; + } + break; + case 121: + if (modifiers) { + result.key = C0.ESC + '[21;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[21~'; + } + break; + case 122: + if (modifiers) { + result.key = C0.ESC + '[23;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[23~'; + } + break; + case 123: + if (modifiers) { + result.key = C0.ESC + '[24;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[24~'; + } + break; + default: + // a-z and space + if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ev.keyCode >= 65 && ev.keyCode <= 90) { + result.key = String.fromCharCode(ev.keyCode - 64); + } else if (ev.keyCode === 32) { + // NUL + result.key = String.fromCharCode(0); + } else if (ev.keyCode >= 51 && ev.keyCode <= 55) { + // escape, file sep, group sep, record sep, unit sep + result.key = String.fromCharCode(ev.keyCode - 51 + 27); + } else if (ev.keyCode === 56) { + // delete + result.key = String.fromCharCode(127); + } else if (ev.keyCode === 219) { + // ^[ - Control Sequence Introducer (CSI) + result.key = String.fromCharCode(27); + } else if (ev.keyCode === 220) { + // ^\ - String Terminator (ST) + result.key = String.fromCharCode(28); + } else if (ev.keyCode === 221) { + // ^] - Operating System Command (OSC) + result.key = String.fromCharCode(29); + } + } else if ((!isMac || macOptionIsMeta) && ev.altKey && !ev.metaKey) { + // On macOS this is a third level shift when !macOptionIsMeta. Use instead. + const keyMapping = KEYCODE_KEY_MAPPINGS[ev.keyCode]; + const key = keyMapping && keyMapping[!ev.shiftKey ? 0 : 1]; + if (key) { + result.key = C0.ESC + key; + } else if (ev.keyCode >= 65 && ev.keyCode <= 90) { + const keyCode = ev.ctrlKey ? ev.keyCode - 64 : ev.keyCode + 32; + result.key = C0.ESC + String.fromCharCode(keyCode); + } + } else if (isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) { + if (ev.keyCode === 65) { // cmd + a + result.type = KeyboardResultType.SELECT_ALL; + } + } + break; + } + + return result; +} diff --git a/src/handlers/AltClickHandler.ts b/src/handlers/AltClickHandler.ts index 8f1cf2df..344a5543 100644 --- a/src/handlers/AltClickHandler.ts +++ b/src/handlers/AltClickHandler.ts @@ -4,7 +4,7 @@ */ import { ITerminal, ICircularList, LineData } from '../Types'; -import { C0 } from '../EscapeSequences'; +import { C0 } from '../common/data/EscapeSequences'; const enum Direction { UP = 'A', diff --git a/src/public/Terminal.test.ts b/src/public/Terminal.test.ts new file mode 100644 index 00000000..06c8f1d5 --- /dev/null +++ b/src/public/Terminal.test.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { Terminal } from './Terminal'; +import * as attach from '../addons/attach/attach'; + +describe('Terminal', () => { + it('should apply addons with Terminal.applyAddon', () => { + Terminal.applyAddon(attach); + // Test that addon was applied successfully, adding attach to Terminal's + // prototype. + assert.equal(typeof (Terminal).prototype.attach, 'function'); + }); +}); diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts new file mode 100644 index 00000000..8ff7cf2b --- /dev/null +++ b/src/public/Terminal.ts @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings } from 'xterm'; +import { ITerminal } from '../Types'; +import { Terminal as TerminalCore } from '../Terminal'; +import * as Strings from '../Strings'; + +export class Terminal implements ITerminalApi { + private _core: ITerminal; + + constructor(options?: ITerminalOptions) { + this._core = new TerminalCore(options); + } + + public get element(): HTMLElement { return this._core.element; } + public get textarea(): HTMLTextAreaElement { return this._core.textarea; } + public get rows(): number { return this._core.rows; } + public get cols(): number { return this._core.cols; } + public get markers(): IMarker[] { return this._core.markers; } + public blur(): void { + this._core.blur(); + } + public focus(): void { + this._core.focus(); + } + public on(type: 'blur' | 'focus' | 'linefeed' | 'selection', listener: () => void): void; + public on(type: 'data', listener: (...args: any[]) => void): void; + public on(type: 'key', listener: (key?: string, event?: KeyboardEvent) => void): void; + public on(type: 'keypress' | 'keydown', listener: (event?: KeyboardEvent) => void): void; + public on(type: 'refresh', listener: (data?: { start: number; end: number; }) => void): void; + public on(type: 'resize', listener: (data?: { cols: number; rows: number; }) => void): void; + public on(type: 'scroll', listener: (ydisp?: number) => void): void; + public on(type: 'title', listener: (title?: string) => void): void; + public on(type: string, listener: (...args: any[]) => void): void; + public on(type: any, listener: any): void { + this._core.on(type, listener); + } + public off(type: string, listener: (...args: any[]) => void): void { + this._core.off(type, listener); + } + public emit(type: string, data?: any): void { + this._core.emit(type, data); + } + public addDisposableListener(type: string, handler: (...args: any[]) => void): IDisposable { + return this._core.addDisposableListener(type, handler); + } + public resize(columns: number, rows: number): void { + this._core.resize(columns, rows); + } + public writeln(data: string): void { + this._core.writeln(data); + } + public open(parent: HTMLElement): void { + this._core.open(parent); + } + public attachCustomKeyEventHandler(customKeyEventHandler: (event: KeyboardEvent) => boolean): void { + this._core.attachCustomKeyEventHandler(customKeyEventHandler); + } + public registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): number { + return this._core.registerLinkMatcher(regex, handler, options); + } + public deregisterLinkMatcher(matcherId: number): void { + this._core.deregisterLinkMatcher(matcherId); + } + public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { + return this._core.registerCharacterJoiner(handler); + } + public deregisterCharacterJoiner(joinerId: number): void { + this._core.deregisterCharacterJoiner(joinerId); + } + public addMarker(cursorYOffset: number): IMarker { + return this._core.addMarker(cursorYOffset); + } + public hasSelection(): boolean { + return this._core.hasSelection(); + } + public getSelection(): string { + return this._core.getSelection(); + } + public clearSelection(): void { + this._core.clearSelection(); + } + public selectAll(): void { + this._core.selectAll(); + } + public selectLines(start: number, end: number): void { + this._core.selectLines(start, end); + } + public dispose(): void { + this._core.dispose(); + } + public destroy(): void { + this._core.destroy(); + } + public scrollLines(amount: number): void { + this._core.scrollLines(amount); + } + public scrollPages(pageCount: number): void { + this._core.scrollPages(pageCount); + } + public scrollToTop(): void { + this._core.scrollToTop(); + } + public scrollToBottom(): void { + this._core.scrollToBottom(); + } + public scrollToLine(line: number): void { + this._core.scrollToLine(line); + } + public clear(): void { + this._core.clear(); + } + public write(data: string): void { + this._core.write(data); + } + public getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold' | 'rendererType' | 'termName'): string; + public getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'debug' | 'disableStdin' | 'enableBold' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell'): boolean; + public getOption(key: 'colors'): string[]; + public getOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; + public getOption(key: 'handler'): (data: string) => void; + public getOption(key: string): any; + public getOption(key: any): any { + return this._core.getOption(key); + } + public setOption(key: 'bellSound' | 'fontFamily' | 'termName', value: string): void; + public setOption(key: 'fontWeight' | 'fontWeightBold', value: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'): void; + public setOption(key: 'bellStyle', value: 'none' | 'visual' | 'sound' | 'both'): void; + public setOption(key: 'cursorStyle', value: 'block' | 'underline' | 'bar'): void; + public setOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'debug' | 'disableStdin' | 'enableBold' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell', value: boolean): void; + public setOption(key: 'colors', value: string[]): void; + public setOption(key: 'fontSize' | 'letterSpacing' | 'lineHeight' | 'tabStopWidth' | 'scrollback', value: number): void; + public setOption(key: 'handler', value: (data: string) => void): void; + public setOption(key: 'theme', value: ITheme): void; + public setOption(key: 'cols' | 'rows', value: number): void; + public setOption(key: string, value: any): void; + public setOption(key: any, value: any): void { + this._core.setOption(key, value); + } + public refresh(start: number, end: number): void { + this._core.refresh(start, end); + } + public reset(): void { + this._core.reset(); + } + public static applyAddon(addon: any): void { + addon.apply(Terminal); + } + public static get strings(): ILocalizableStrings { + return Strings; + } +} diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index b2a40290..2e9de389 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -49,7 +49,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { public onFocus(terminal: ITerminal): void {} public onCursorMove(terminal: ITerminal): void {} public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void {} - public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void {} + public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean = false): void {} public onThemeChanged(terminal: ITerminal, colorSet: IColorSet): void { this._refreshCharAtlas(terminal, colorSet); diff --git a/src/renderer/ColorManager.test.ts b/src/renderer/ColorManager.test.ts index 0012c1ed..26fb2895 100644 --- a/src/renderer/ColorManager.test.ts +++ b/src/renderer/ColorManager.test.ts @@ -36,7 +36,7 @@ describe('ColorManager', () => { for (const key of Object.keys(cm.colors)) { if (key !== 'ansi') { // A #rrggbb or rgba(...) - assert.ok(cm.colors[key].css.length >= 7); + assert.ok((cm.colors)[key].css.length >= 7); } } assert.equal(cm.colors.ansi.length, 256); diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index f2fa8a3d..02ea5d2a 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -11,8 +11,8 @@ import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions, ICharacterJoiner import { ITerminal, CharacterJoinerHandler } from '../Types'; import { LinkRenderLayer } from './LinkRenderLayer'; import { EventEmitter } from '../EventEmitter'; -import { RenderDebouncer } from '../utils/RenderDebouncer'; -import { ScreenDprMonitor } from '../utils/ScreenDprMonitor'; +import { RenderDebouncer } from '../ui/RenderDebouncer'; +import { ScreenDprMonitor } from '../ui/ScreenDprMonitor'; import { ITheme } from 'xterm'; export class Renderer extends EventEmitter implements IRenderer { @@ -64,12 +64,14 @@ export class Renderer extends EventEmitter implements IRenderer { this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); this._screenDprMonitor = new ScreenDprMonitor(); this._screenDprMonitor.setListener(() => this.onWindowResize(window.devicePixelRatio)); + this.register(this._screenDprMonitor); // Detect whether IntersectionObserver is detected and enable renderer pause // and resume based on terminal visibility if so if ('IntersectionObserver' in window) { const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), { threshold: 0 }); observer.observe(this._terminal.element); + this.register({ dispose: () => observer.disconnect() }); } } @@ -143,8 +145,8 @@ export class Renderer extends EventEmitter implements IRenderer { this._runOperation(l => l.onFocus(this._terminal)); } - public onSelectionChanged(start: [number, number], end: [number, number]): void { - this._runOperation(l => l.onSelectionChanged(this._terminal, start, end)); + public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean = false): void { + this._runOperation(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode)); } public onCursorMove(): void { diff --git a/src/renderer/SelectionRenderLayer.ts b/src/renderer/SelectionRenderLayer.ts index e2212246..1759e112 100644 --- a/src/renderer/SelectionRenderLayer.ts +++ b/src/renderer/SelectionRenderLayer.ts @@ -8,13 +8,14 @@ import { IColorSet, IRenderDimensions } from './Types'; import { BaseRenderLayer } from './BaseRenderLayer'; export class SelectionRenderLayer extends BaseRenderLayer { - private _state: {start: [number, number], end: [number, number]}; + private _state: { start: [number, number], end: [number, number], columnSelectMode: boolean }; constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { super(container, 'selection', zIndex, true, colors); this._state = { start: null, - end: null + end: null, + columnSelectMode: null }; } @@ -23,7 +24,8 @@ export class SelectionRenderLayer extends BaseRenderLayer { // Resizing the canvas discards the contents of the canvas so clear state this._state = { start: null, - end: null + end: null, + columnSelectMode: null }; } @@ -31,15 +33,16 @@ export class SelectionRenderLayer extends BaseRenderLayer { if (this._state.start && this._state.end) { this._state = { start: null, - end: null + end: null, + columnSelectMode: null }; this.clearAll(); } } - public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void { + public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean): void { // Selection has not changed - if (this._state.start === start || this._state.end === end) { + if (!this._didStateChange(start, end, columnSelectMode)) { return; } @@ -62,25 +65,48 @@ export class SelectionRenderLayer extends BaseRenderLayer { return; } - // Draw first row - const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; - const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols; this._ctx.fillStyle = this._colors.selection.css; - this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1); - // Draw middle rows - const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0); - this.fillCells(0, viewportCappedStartRow + 1, terminal.cols, middleRowsCount); + if (columnSelectMode) { + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const width = end[0] - startCol; + const height = viewportCappedEndRow - viewportCappedStartRow + 1; + this.fillCells(startCol, viewportCappedStartRow, width, height); + } else { + // Draw first row + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols; + this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1); - // Draw final row - if (viewportCappedStartRow !== viewportCappedEndRow) { - // Only draw viewportEndRow if it's not the same as viewportStartRow - const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : terminal.cols; - this.fillCells(0, viewportCappedEndRow, endCol, 1); + // Draw middle rows + const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0); + this.fillCells(0, viewportCappedStartRow + 1, terminal.cols, middleRowsCount); + + // Draw final row + if (viewportCappedStartRow !== viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewportStartRow + const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : terminal.cols; + this.fillCells(0, viewportCappedEndRow, endCol, 1); + } } // Save state for next render this._state.start = [start[0], start[1]]; this._state.end = [end[0], end[1]]; + this._state.columnSelectMode = columnSelectMode; + } + + private _didStateChange(start: [number, number], end: [number, number], columnSelectMode: boolean): boolean { + return !this._areCoordinatesEqual(start, this._state.start) || + !this._areCoordinatesEqual(end, this._state.end) || + columnSelectMode !== this._state.columnSelectMode; + } + + private _areCoordinatesEqual(coord1: [number, number], coord2: [number, number]): boolean { + if (!coord1 || !coord2) { + return false; + } + + return coord1[0] === coord2[0] && coord1[1] === coord2[1]; } } diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index 6cf86ade..cb5f41b0 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -260,7 +260,9 @@ export class TextRenderLayer extends BaseRenderLayer { return; } - this._charAtlas.beginFrame(); + if (this._charAtlas) { + this._charAtlas.beginFrame(); + } this.clearCells(0, firstRow, terminal.cols, lastRow - firstRow + 1); this._drawBackground(terminal, firstRow, lastRow); diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index c6110d4f..7f95949a 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -4,7 +4,7 @@ */ import { ITerminal, CharacterJoinerHandler } from '../Types'; -import { IEventEmitter, ITheme } from 'xterm'; +import { IEventEmitter, ITheme, IDisposable } from 'xterm'; import { IColorSet } from '../shared/Types'; /** @@ -20,17 +20,22 @@ export const enum FLAGS { ITALIC = 64 } -export interface IRenderer extends IEventEmitter { +/** + * Note that IRenderer implementations should emit the refresh event after + * rendering rows to the screen. + */ +export interface IRenderer extends IEventEmitter, IDisposable { dimensions: IRenderDimensions; colorManager: IColorManager; + dispose(): void; setTheme(theme: ITheme): IColorSet; onWindowResize(devicePixelRatio: number): void; onResize(cols: number, rows: number): void; onCharSizeChanged(): void; onBlur(): void; onFocus(): void; - onSelectionChanged(start: [number, number], end: [number, number]): void; + onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void; onCursorMove(): void; onOptionsChanged(): void; clear(): void; @@ -96,7 +101,7 @@ export interface IRenderLayer { /** * Calls when the selection changes. */ - onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void; + onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean): void; /** * Registers a handler to join characters to render as a group diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts index 39284d32..59ac07df 100644 --- a/src/renderer/atlas/CharAtlasUtils.ts +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -9,7 +9,7 @@ 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 = { + const clonedColors = { foreground: colors.foreground, background: colors.background, cursor: null, diff --git a/src/renderer/atlas/LRUMap.ts b/src/renderer/atlas/LRUMap.ts index 4a03f2aa..eccfbfea 100644 --- a/src/renderer/atlas/LRUMap.ts +++ b/src/renderer/atlas/LRUMap.ts @@ -11,7 +11,7 @@ interface ILinkedListNode { } export default class LRUMap { - private _map = {}; + private _map: { [key: string]: ILinkedListNode } = {}; private _head: ILinkedListNode = null; private _tail: ILinkedListNode = null; private _nodePool: ILinkedListNode[] = []; diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts index cb4875ec..b6de82fc 100644 --- a/src/renderer/atlas/StaticCharAtlas.ts +++ b/src/renderer/atlas/StaticCharAtlas.ts @@ -26,7 +26,7 @@ export default class StaticCharAtlas extends BaseCharAtlas { return canvas; } - public _doWarmUp(): void { + protected _doWarmUp(): void { const result = generateStaticCharAtlasTexture(window, this._canvasFactory, this._config); if (result instanceof HTMLCanvasElement) { this._texture = result; diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts new file mode 100644 index 00000000..c32d6634 --- /dev/null +++ b/src/renderer/dom/DomRenderer.ts @@ -0,0 +1,325 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderer, IRenderDimensions, IColorSet } from '../Types'; +import { ITerminal, CharacterJoinerHandler } from '../../Types'; +import { ITheme } from 'xterm'; +import { EventEmitter } from '../../EventEmitter'; +import { ColorManager } from '../ColorManager'; +import { RenderDebouncer } from '../../ui/RenderDebouncer'; +import { BOLD_CLASS, ITALIC_CLASS, CURSOR_CLASS, DomRendererRowFactory } from './DomRendererRowFactory'; + +const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; +const ROW_CONTAINER_CLASS = 'xterm-rows'; +const FG_CLASS_PREFIX = 'xterm-fg-'; +const BG_CLASS_PREFIX = 'xterm-bg-'; +const FOCUS_CLASS = 'xterm-focus'; +const SELECTION_CLASS = 'xterm-selection'; + +let nextTerminalId = 1; + +// TODO: Pull into an addon when TS composite projects allow easier sharing of code (not just +// interfaces) between core and addons + +/** + * A fallback renderer for when canvas is slow. This is not meant to be + * particularly fast or feature complete, more just stable and usable for when + * canvas is not an option. + */ +export class DomRenderer extends EventEmitter implements IRenderer { + private _renderDebouncer: RenderDebouncer; + private _rowFactory: DomRendererRowFactory; + private _terminalClass: number = nextTerminalId++; + + private _themeStyleElement: HTMLStyleElement; + private _dimensionsStyleElement: HTMLStyleElement; + private _rowContainer: HTMLElement; + private _rowElements: HTMLElement[] = []; + private _selectionContainer: HTMLElement; + + public dimensions: IRenderDimensions; + public colorManager: ColorManager; + + constructor(private _terminal: ITerminal, theme: ITheme | undefined) { + super(); + const allowTransparency = this._terminal.options.allowTransparency; + this.colorManager = new ColorManager(document, allowTransparency); + this.setTheme(theme); + + this._rowContainer = document.createElement('div'); + this._rowContainer.classList.add(ROW_CONTAINER_CLASS); + this._rowContainer.style.lineHeight = 'normal'; + this._rowContainer.setAttribute('aria-hidden', 'true'); + this._refreshRowElements(this._terminal.rows, this._terminal.cols); + this._selectionContainer = document.createElement('div'); + this._selectionContainer.classList.add(SELECTION_CLASS); + this._selectionContainer.setAttribute('aria-hidden', 'true'); + + this.dimensions = { + scaledCharWidth: null, + scaledCharHeight: null, + scaledCellWidth: null, + scaledCellHeight: null, + scaledCharLeft: null, + scaledCharTop: null, + scaledCanvasWidth: null, + scaledCanvasHeight: null, + canvasWidth: null, + canvasHeight: null, + actualCellWidth: null, + actualCellHeight: null + }; + this._updateDimensions(); + + this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._rowFactory = new DomRendererRowFactory(document); + + this._terminal.element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); + this._terminal.screenElement.appendChild(this._rowContainer); + this._terminal.screenElement.appendChild(this._selectionContainer); + } + + private _updateDimensions(): void { + this.dimensions.scaledCharWidth = this._terminal.charMeasure.width * window.devicePixelRatio; + this.dimensions.scaledCharHeight = this._terminal.charMeasure.height * window.devicePixelRatio; + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth; + this.dimensions.scaledCellHeight = this.dimensions.scaledCharHeight; + this.dimensions.scaledCharLeft = 0; + this.dimensions.scaledCharTop = 0; + this.dimensions.scaledCanvasWidth = this.dimensions.scaledCellWidth * this._terminal.cols; + this.dimensions.scaledCanvasHeight = this.dimensions.scaledCellHeight * this._terminal.rows; + this.dimensions.canvasWidth = this._terminal.charMeasure.width * this._terminal.cols; + this.dimensions.canvasHeight = this._terminal.charMeasure.height * this._terminal.rows; + this.dimensions.actualCellWidth = this._terminal.charMeasure.width; + this.dimensions.actualCellHeight = this._terminal.charMeasure.height; + + this._rowElements.forEach(element => { + element.style.width = `${this.dimensions.canvasWidth}px`; + element.style.height = `${this._terminal.charMeasure.height}px`; + }); + + if (!this._dimensionsStyleElement) { + this._dimensionsStyleElement = document.createElement('style'); + this._terminal.screenElement.appendChild(this._dimensionsStyleElement); + } + + const styles = + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + + ` display: inline-block;` + + ` height: 100%;` + + ` vertical-align: top;` + + ` width: ${this._terminal.charMeasure.width}px` + + `}`; + + this._dimensionsStyleElement.innerHTML = styles; + + this._selectionContainer.style.height = (this._terminal)._viewportElement.style.height; + this._rowContainer.style.width = `${this.dimensions.canvasWidth}px`; + this._rowContainer.style.height = `${this.dimensions.canvasHeight}px`; + } + + public setTheme(theme: ITheme | undefined): IColorSet { + if (theme) { + this.colorManager.setTheme(theme); + } + + if (!this._themeStyleElement) { + this._themeStyleElement = document.createElement('style'); + this._terminal.screenElement.appendChild(this._themeStyleElement); + } + + // Base CSS + let styles = + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` + + ` color: ${this.colorManager.colors.foreground.css};` + + ` background-color: ${this.colorManager.colors.background.css};` + + ` font-family: ${this._terminal.getOption('fontFamily')};` + + ` font-size: ${this._terminal.getOption('fontSize')}px;` + + `}`; + // Text styles + styles += + `${this._terminalSelector} span:not(.${BOLD_CLASS}) {` + + ` font-weight: ${this._terminal.options.fontWeight};` + + `}` + + `${this._terminalSelector} span.${BOLD_CLASS} {` + + ` font-weight: ${this._terminal.options.fontWeightBold};` + + `}` + + `${this._terminalSelector} span.${ITALIC_CLASS} {` + + ` font-style: italic;` + + `}`; + // Cursor + styles += + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS} {` + + ` background-color: ${this.colorManager.colors.cursor.css};` + + ` color: ${this.colorManager.colors.cursorAccent.css};` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS} {` + + ` outline: 1px solid #fff;` + + ` outline-offset: -1px;` + + `}`; + // Selection + styles += + `${this._terminalSelector} .${SELECTION_CLASS} {` + + ` position: absolute;` + + ` top: 0;` + + ` left: 0;` + + ` z-index: 1;` + + ` pointer-events: none;` + + `}` + + `${this._terminalSelector} .${SELECTION_CLASS} div {` + + ` position: absolute;` + + ` background-color: ${this.colorManager.colors.selection.css};` + + `}`; + // Colors + this.colorManager.colors.ansi.forEach((c, i) => { + styles += + `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + + `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; + }); + + this._themeStyleElement.innerHTML = styles; + return this.colorManager.colors; + } + + public onWindowResize(devicePixelRatio: number): void { + this._updateDimensions(); + } + + private _refreshRowElements(cols: number, rows: number): void { + // Add missing elements + for (let i = this._rowElements.length; i <= rows; i++) { + const row = document.createElement('div'); + this._rowContainer.appendChild(row); + this._rowElements.push(row); + } + // Remove excess elements + while (this._rowElements.length > rows) { + this._rowContainer.removeChild(this._rowElements.pop()); + } + } + + public onResize(cols: number, rows: number): void { + this._refreshRowElements(cols, rows); + this._updateDimensions(); + } + + public onCharSizeChanged(): void { + this._updateDimensions(); + } + + public onBlur(): void { + this._rowContainer.classList.remove(FOCUS_CLASS); + } + + public onFocus(): void { + this._rowContainer.classList.add(FOCUS_CLASS); + } + + public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void { + // Remove all selections + while (this._selectionContainer.children.length) { + this._selectionContainer.removeChild(this._selectionContainer.children[0]); + } + + // Selection does not exist + if (!start || !end) { + return; + } + + // Translate from buffer position to viewport position + const viewportStartRow = start[1] - this._terminal.buffer.ydisp; + const viewportEndRow = end[1] - this._terminal.buffer.ydisp; + const viewportCappedStartRow = Math.max(viewportStartRow, 0); + const viewportCappedEndRow = Math.min(viewportEndRow, this._terminal.rows - 1); + + // No need to draw the selection + if (viewportCappedStartRow >= this._terminal.rows || viewportCappedEndRow < 0) { + return; + } + + // Create the selections + const documentFragment = document.createDocumentFragment(); + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + + if (columnSelectMode) { + documentFragment.appendChild( + this._createSelectionElement(viewportCappedStartRow, startCol, end[0], viewportCappedEndRow - viewportStartRow + 1) + ); + } else { + // Draw first row + const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol)); + // Draw middle rows + const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount)); + // Draw final row + if (viewportCappedStartRow !== viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewporttartRow + const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol)); + } + } + this._selectionContainer.appendChild(documentFragment); + } + + /** + * Creates a selection element at the specified position. + * @param row The row of the selection. + * @param colStart The start column. + * @param colEnd The end columns. + */ + private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement { + const element = document.createElement('div'); + element.style.height = `${rowCount * this._terminal.charMeasure.height}px`; + element.style.top = `${row * this._terminal.charMeasure.height}px`; + element.style.left = `${colStart * this._terminal.charMeasure.width}px`; + element.style.width = `${this._terminal.charMeasure.width * (colEnd - colStart)}px`; + return element; + } + + public onCursorMove(): void { + // No-op, the cursor is drawn when rows are drawn + } + + public onOptionsChanged(): void { + // Force a refresh + this._updateDimensions(); + this.setTheme(undefined); + this._terminal.refresh(0, this._terminal.rows - 1); + } + + public clear(): void { + this._rowElements.forEach(e => e.innerHTML = ''); + } + + public refreshRows(start: number, end: number): void { + this._renderDebouncer.refresh(start, end); + } + + private _renderRows(start: number, end: number): void { + const terminal = this._terminal; + + const cursorAbsoluteY = terminal.buffer.ybase + terminal.buffer.y; + const cursorX = this._terminal.buffer.x; + + for (let y = start; y <= end; y++) { + const rowElement = this._rowElements[y]; + rowElement.innerHTML = ''; + + const row = y + terminal.buffer.ydisp; + const lineData = terminal.buffer.lines.get(row); + rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorX, terminal.charMeasure.width, terminal.cols)); + } + + this._terminal.emit('refresh', {start, end}); + } + + private get _terminalSelector(): string { + return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`; + } + + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { return -1; } + public deregisterCharacterJoiner(joinerId: number): boolean { return false; } +} diff --git a/src/renderer/dom/DomRendererRowFactory.test.ts b/src/renderer/dom/DomRendererRowFactory.test.ts new file mode 100644 index 00000000..e91c71fe --- /dev/null +++ b/src/renderer/dom/DomRendererRowFactory.test.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { DomRendererRowFactory } from './DomRendererRowFactory'; +import { LineData } from '../../Types'; +import { DEFAULT_ATTR } from '../../Buffer'; +import { FLAGS } from '../Types'; + +describe('DomRendererRowFactory', () => { + let dom: jsdom.JSDOM; + let rowFactory: DomRendererRowFactory; + let lineData: LineData; + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + rowFactory = new DomRendererRowFactory(dom.window.document); + lineData = createEmptyLineData(2); + }); + + describe('createRow', () => { + it('should create an element for every character in the row', () => { + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + ' ' + + ' ' + ); + }); + + it('should set correct attributes for double width characters', () => { + lineData[0] = [DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]; + // There should be no element for the following "empty" cell + lineData[1] = [DEFAULT_ATTR, '', 0, undefined]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + '' + ); + }); + + it('should add class for cursor', () => { + const fragment = rowFactory.createRow(lineData, true, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + ' ' + + ' ' + ); + }); + + it('should not render cells that go beyond the terminal\'s columns', () => { + lineData[0] = [DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]; + lineData[1] = [DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 1); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + + describe('attributes', () => { + it('should add class for bold', () => { + lineData[0] = [DEFAULT_ATTR | (FLAGS.BOLD << 18), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should add class for italic', () => { + lineData[0] = [DEFAULT_ATTR | (FLAGS.ITALIC << 18), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should add classes for 256 foreground colors', () => { + const defaultAttrNoFgColor = (0 << 9) | (256 << 0); + for (let i = 0; i < 256; i++) { + lineData[0] = [defaultAttrNoFgColor | (i << 9), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + + it('should add classes for 256 background colors', () => { + const defaultAttrNoBgColor = (257 << 9) | (0 << 0); + for (let i = 0; i < 256; i++) { + lineData[0] = [defaultAttrNoBgColor | (i << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + + it('should correctly invert colors', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (2 << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should correctly invert default fg color', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (257 << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should correctly invert default bg color', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (1 << 9) | (256 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should turn bold fg text bright', () => { + for (let i = 0; i < 8; i++) { + lineData[0] = [(FLAGS.BOLD << 18) | (i << 9) | (256 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5, 20); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + }); + }); + + function getFragmentHtml(fragment: DocumentFragment): string { + const element = dom.window.document.createElement('div'); + element.appendChild(fragment); + return element.innerHTML; + } + + function createEmptyLineData(cols: number): LineData { + const lineData: LineData = []; + for (let i = 0; i < cols; i++) { + lineData.push([DEFAULT_ATTR, ' ', 1, 32 /* ' '.charCodeAt(0) */]); + } + return lineData; + } +}); diff --git a/src/renderer/dom/DomRendererRowFactory.ts b/src/renderer/dom/DomRendererRowFactory.ts new file mode 100644 index 00000000..eedb1d34 --- /dev/null +++ b/src/renderer/dom/DomRendererRowFactory.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { LineData } from '../../Types'; +import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_ATTR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../../Buffer'; +import { FLAGS } from '../Types'; + +export const BOLD_CLASS = 'xterm-bold'; +export const ITALIC_CLASS = 'xterm-italic'; +export const CURSOR_CLASS = 'xterm-cursor'; + +export class DomRendererRowFactory { + constructor( + private _document: Document + ) { + } + + public createRow(lineData: LineData, isCursorRow: boolean, cursorX: number, cellWidth: number, cols: number): DocumentFragment { + const fragment = this._document.createDocumentFragment(); + let colCount = 0; + + for (let x = 0; x < lineData.length; x++) { + // Don't allow any buffer to the right to be displayed + if (colCount >= cols) { + continue; + } + + const charData = lineData[x]; + const char: string = charData[CHAR_DATA_CHAR_INDEX]; + const attr: number = charData[CHAR_DATA_ATTR_INDEX]; + const width: number = charData[CHAR_DATA_WIDTH_INDEX]; + + // The character to the left is a wide character, drawing is owned by the char at x-1 + if (width === 0) { + continue; + } + + const charElement = this._document.createElement('span'); + if (width > 1) { + charElement.style.width = `${cellWidth * width}px`; + } + + const flags = attr >> 18; + let bg = attr & 0x1ff; + let fg = (attr >> 9) & 0x1ff; + + if (isCursorRow && x === cursorX) { + charElement.classList.add(CURSOR_CLASS); + } + + // If inverse flag is on, the foreground should become the background. + if (flags & FLAGS.INVERSE) { + const temp = bg; + bg = fg; + fg = temp; + if (fg === 256) { + fg = 0; + } + if (bg === 257) { + bg = 15; + } + } + + if (flags & FLAGS.BOLD) { + // Convert the FG color to the bold variant + if (fg < 8) { + fg += 8; + } + charElement.classList.add(BOLD_CLASS); + } + + if (flags & FLAGS.ITALIC) { + charElement.classList.add(ITALIC_CLASS); + } + + charElement.textContent = char; + if (fg !== 257) { + charElement.classList.add(`xterm-fg-${fg}`); + } + if (bg !== 256) { + charElement.classList.add(`xterm-bg-${bg}`); + } + fragment.appendChild(charElement); + colCount += width; + } + return fragment; + } +} diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts index e0cfbcf7..f78a6d41 100644 --- a/src/shared/atlas/CharAtlasGenerator.ts +++ b/src/shared/atlas/CharAtlasGenerator.ts @@ -106,7 +106,7 @@ export function generateStaticCharAtlasTexture(context: Window, canvasFactory: ( return canvas; } // Transfer to an ImageBitmap is this is an OffscreenCanvas - return new Promise(r => r(canvas.transferToImageBitmap())); + return new Promise((r: (bitmap: ImageBitmap) => void) => r(canvas.transferToImageBitmap())); } const charAtlasImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); diff --git a/src/utils/CharMeasure.test.ts b/src/ui/CharMeasure.test.ts similarity index 100% rename from src/utils/CharMeasure.test.ts rename to src/ui/CharMeasure.test.ts diff --git a/src/utils/CharMeasure.ts b/src/ui/CharMeasure.ts similarity index 100% rename from src/utils/CharMeasure.ts rename to src/ui/CharMeasure.ts diff --git a/src/utils/Dom.ts b/src/ui/Lifecycle.ts similarity index 94% rename from src/utils/Dom.ts rename to src/ui/Lifecycle.ts index 889c88c2..d8367113 100644 --- a/src/utils/Dom.ts +++ b/src/ui/Lifecycle.ts @@ -10,7 +10,7 @@ import { IDisposable } from 'xterm'; * @param type The event type. * @param handler The handler for the listener. */ -export function addDisposableListener( +export function addDisposableDomListener( node: Element | Window | Document, type: string, handler: (e: any) => void, diff --git a/src/input/MouseZoneManager.ts b/src/ui/MouseZoneManager.ts similarity index 94% rename from src/input/MouseZoneManager.ts rename to src/ui/MouseZoneManager.ts index 65fe74de..491e2a05 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/ui/MouseZoneManager.ts @@ -5,6 +5,8 @@ import { ITerminal } from '../Types'; import { IMouseZoneManager, IMouseZone } from './Types'; +import { Disposable } from '../common/Lifecycle'; +import { addDisposableDomListener } from './Lifecycle'; const HOVER_DURATION = 500; @@ -16,7 +18,7 @@ const HOVER_DURATION = 500; * needed to support was single-line links which never overlap. Improvements can * be made in the future. */ -export class MouseZoneManager implements IMouseZoneManager { +export class MouseZoneManager extends Disposable implements IMouseZoneManager { private _zones: IMouseZone[] = []; private _areZonesActive: boolean = false; @@ -30,13 +32,20 @@ export class MouseZoneManager implements IMouseZoneManager { constructor( private _terminal: ITerminal ) { - this._terminal.element.addEventListener('mousedown', e => this._onMouseDown(e)); + super(); + + this.register(addDisposableDomListener(this._terminal.element, 'mousedown', e => this._onMouseDown(e))); // These events are expensive, only listen to it when mouse zones are active this._mouseMoveListener = e => this._onMouseMove(e); this._clickListener = e => this._onClick(e); } + public dispose(): void { + super.dispose(); + this._deactivate(); + } + public add(zone: IMouseZone): void { this._zones.push(zone); if (this._zones.length === 1) { diff --git a/src/utils/RenderDebouncer.ts b/src/ui/RenderDebouncer.ts similarity index 100% rename from src/utils/RenderDebouncer.ts rename to src/ui/RenderDebouncer.ts diff --git a/src/utils/ScreenDprMonitor.ts b/src/ui/ScreenDprMonitor.ts similarity index 91% rename from src/utils/ScreenDprMonitor.ts rename to src/ui/ScreenDprMonitor.ts index 15f3ac00..9247a032 100644 --- a/src/utils/ScreenDprMonitor.ts +++ b/src/ui/ScreenDprMonitor.ts @@ -3,6 +3,8 @@ * @license MIT */ +import { Disposable } from '../common/Lifecycle'; + export type ScreenDprListener = (newDevicePixelRatio?: number, oldDevicePixelRatio?: number) => void; /** @@ -15,7 +17,7 @@ export type ScreenDprListener = (newDevicePixelRatio?: number, oldDevicePixelRat * The listener should fire on both window zoom changes and switching to a * monitor with a different DPI. */ -export class ScreenDprMonitor { +export class ScreenDprMonitor extends Disposable { private _currentDevicePixelRatio: number; private _outerListener: MediaQueryListListener; private _listener: ScreenDprListener; @@ -33,6 +35,11 @@ export class ScreenDprMonitor { this._updateDpr(); } + public dispose(): void { + super.dispose(); + this.clearListener(); + } + private _updateDpr(): void { // Clear listeners for old DPR if (this._resolutionMediaMatchList) { diff --git a/src/input/Types.ts b/src/ui/Types.ts similarity index 83% rename from src/input/Types.ts rename to src/ui/Types.ts index 2bd805bf..89dfa1a4 100644 --- a/src/input/Types.ts +++ b/src/ui/Types.ts @@ -3,7 +3,9 @@ * @license MIT */ -export interface IMouseZoneManager { +import { IDisposable } from 'xterm'; + +export interface IMouseZoneManager extends IDisposable { add(zone: IMouseZone): void; clearAll(start?: number, end?: number): void; } diff --git a/src/utils/Clone.test.ts b/src/utils/Clone.test.ts index f5708a9e..b24452c8 100644 --- a/src/utils/Clone.test.ts +++ b/src/utils/Clone.test.ts @@ -39,7 +39,7 @@ describe('clone', () => { }); it('should clone null values', () => { - const test = { + const test: any = { a: null }; diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 6e95e2a7..78a1942b 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -107,7 +107,6 @@ export class MockTerminal implements ITerminal { children: HTMLElement[]; cursorHidden: boolean; cursorState: number; - defAttr: number; scrollback: number; buffers: IBufferSet; buffer: IBuffer; @@ -184,8 +183,8 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { insertMode: boolean; wraparoundMode: boolean; bracketedPasteMode: boolean; - defAttr: number; curAttr: number; + savedCurAttr: number; savedCols: number; x10Mouse: boolean; vt200Mouse: boolean; @@ -269,7 +268,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { throw new Error('Method not implemented.'); } setOption(key: string, value: any): void { - this.options[key] = value; + (this.options)[key] = value; } on(type: string, listener: XtermListener): void { throw new Error('Method not implemented.'); @@ -304,6 +303,9 @@ export class MockBuffer implements IBuffer { translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string { return Buffer.prototype.translateBufferLineToString.apply(this, arguments); } + getWrappedRangeForLine(y: number): { first: number; last: number; } { + throw new Error('Method not implemented.'); + } nextStop(x?: number): number { throw new Error('Method not implemented.'); } @@ -316,6 +318,9 @@ export class MockBuffer implements IBuffer { } export class MockRenderer implements IRenderer { + dispose(): void { + throw new Error('Method not implemented.'); + } colorManager: IColorManager; on(type: string, listener: XtermListener): void { throw new Error('Method not implemented.'); @@ -346,6 +351,9 @@ export class MockRenderer implements IRenderer { } export class MockViewport implements IViewport { + dispose(): void { + throw new Error('Method not implemented.'); + } scrollBarWidth: number = 0; onThemeChanged(colors: IColorSet): void { throw new Error('Method not implemented.'); diff --git a/src/xterm.css b/src/xterm.css index 6e7d2f96..8e129f50 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -139,6 +139,11 @@ cursor: pointer; } +.xterm.xterm-cursor-crosshair { + /* Column selection mode */ + cursor: crosshair; +} + .xterm .xterm-accessibility, .xterm .xterm-message { position: absolute; diff --git a/src/xterm.ts b/src/xterm.ts index 6df3a4c0..81b95805 100644 --- a/src/xterm.ts +++ b/src/xterm.ts @@ -5,6 +5,6 @@ * This file is the entry point for browserify. */ -import { Terminal } from './Terminal'; +import { Terminal } from './public/Terminal'; module.exports = Terminal; diff --git a/tsconfig.json b/tsconfig.json index 2e7404e2..d7085822 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,16 +3,18 @@ "module": "commonjs", "target": "es5", "lib": [ - "DOM", - "ES5", - "ScriptHost", - "ES2015.Promise" + "dom", + "es5", + "scripthost", + "es2015.promise" ], "rootDir": "src", "outDir": "lib", "sourceMap": true, "removeComments": true, - "noUnusedLocals": true + "preserveWatchOutput": true, + "noUnusedLocals": true, + "noImplicitAny": true }, "include": [ "src/**/*", diff --git a/tslint.json b/tslint.json index 8b364077..2ae39796 100644 --- a/tslint.json +++ b/tslint.json @@ -94,7 +94,16 @@ "naming-convention": [ true, - {"type": "property", "modifiers": ["public", "static", "const"], "format": "UPPER_CASE"} + {"type": "default", "format": "camelCase", "leadingUnderscore": "forbid"}, + {"type": "type", "format": "PascalCase"}, + {"type": "class", "format": "PascalCase"}, + {"type": "property", "modifiers": ["const"], "format": "UPPER_CASE"}, + {"type": "member", "modifiers": ["protected"], "format": "camelCase", "leadingUnderscore": "allow"}, + // TODO: Change allow to require when there aren't many PRs out + // {"type": "member", "modifiers": ["protected"], "format": "camelCase", "leadingUnderscore": "require"}, + {"type": "member", "modifiers": ["private"], "format": "camelCase", "leadingUnderscore": "require"}, + {"type": "variable", "modifiers": ["const"], "format": ["camelCase", "UPPER_CASE"]}, + {"type": "interface", "prefix": "I"} ], "no-else-after-return": { "options": "allow-else-if" diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index dff5cf08..2a082134 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -13,6 +13,11 @@ declare module 'xterm' { */ export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + /** + * A string representing a renderer type. + */ + export type RendererType = 'dom' | 'canvas'; + /** * An object containing start up options for the terminal. */ @@ -119,6 +124,32 @@ declare module 'xterm' { */ macOptionIsMeta?: boolean; + /** + * Whether holding a modifier key will force normal selection behavior, + * regardless of whether the terminal is in mouse events mode. This will + * also prevent mouse events from being emitted by the terminal. For example, + * this allows you to use xterm.js' regular selection inside tmux with + * mouse mode enabled. + */ + macOptionClickForcesSelection?: boolean; + + /** + * (EXPERIMENTAL) The type of renderer to use, this allows using the + * fallback DOM renderer when canvas is too slow for the environment. The + * following features do not work when the DOM renderer is used: + * + * - Links + * - Line height + * - Letter spacing + * - Cursor blink + * - Cursor style + * + * This option is marked as experiemental because it will eventually be + * moved to an addon. You can only set this option in the constructor (not + * setOption). + */ + rendererType?: RendererType; + /** * Whether to select the word under the cursor on right click, this is * standard behavior in a lot of macOS applications. @@ -550,7 +581,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'termName'): string; + getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'rendererType' | 'termName'): string; /** * Retrieves an option's value from the terminal. * @param key The option key.