diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..4d4e605b9 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,22 @@ +FROM node:10 + +# Configure apt +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get -y install --no-install-recommends apt-utils 2>&1 + +# Verify git and process tools are installed +RUN apt-get install -y git procps + +# Install yarn +RUN apt-get install -y curl apt-transport-https lsb-release \ + && curl -sS https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/pubkey.gpg | apt-key add - 2>/dev/null \ + && echo "deb https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ + && apt-get update \ + && apt-get -y install --no-install-recommends yarn + +# Clean up +RUN apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* +ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..b00a26936 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "xterm.js", + "dockerFile": "Dockerfile", + "appPort": 3000, + "extensions": [ + "editorconfig.editorconfig", + "ms-vscode.vscode-typescript-tslint-plugin" + ] +} diff --git a/.npmignore b/.npmignore index 517ba0bdc..03030ba59 100644 --- a/.npmignore +++ b/.npmignore @@ -42,6 +42,7 @@ lib/test/ docs/ /.idea/ .vscode/ +bin/ build/ fixtures/ coverage/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..3c8a2dc2e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.preferences.quoteStyle": "single" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..2f752faf0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,51 @@ +{ + "version": "2.0.0", + "presentation": { + "echo": false, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true + }, + "tasks": [ + { + "type": "npm", + "script": "test", + "group": "test", + "problemMatcher": [] + }, + { + "type": "npm", + "script": "watch", + "group": "build", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "group": "vscode" + } + }, + { + "type": "npm", + "script": "start", + "group": "build", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "group": "vscode" + } + }, + { + "label": "Start demo", + "dependsOn": ["npm: watch", "npm: start"], + "group": { + "kind": "build", + "isDefault": true + }, + "isBackground": true, + "problemMatcher": [], + "presentation": { + "group": "vscode" + } + } + ] +} diff --git a/README.md b/README.md index 1fa8ad41e..83170eff5 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**Jumpserver**](https://github.com/jumpserver/luna): Jumpserver Luna project, Jumpserver is a bastion server project, Luna use xterm.js for web terminal emulation. - [**LxdMosaic**](https://github.com/turtle0x1/LxdMosaic): Uses xterm.js to give terminal access to containers through LXD - [**CodeInterview.io**](https://codeinterview.io): A coding interview platform in 25+ languages and many web frameworks. Uses xterm.js to provide shell access. -- [**Bastillion**](https://www.bastillion.io): Bastillion is an open-source web-based SSH console that centrally manages administrative access to systems. +- [**Bastillion**](https://www.bastillion.io): Bastillion is an open-source web-based SSH console that centrally manages administrative access to systems. +- [**PHP App Server**](https://github.com/cubiclesoft/php-app-server/): Create lightweight, installable almost-native applications for desktop OSes. ExecTerminal (nicely wraps the xterm.js Terminal), TerminalManager, and RunProcessSDK are self-contained, reusable ES5+ compliant Javascript components. [And much more...](https://github.com/xtermjs/xterm.js/network/dependents) @@ -168,6 +169,16 @@ Xterm.js follows a monthly release cycle roughly. All current and past releases are available on this repo's [Releases page](https://github.com/sourcelair/xterm.js/releases), you can view the [high-level roadmap on the wiki](https://github.com/xtermjs/xterm.js/wiki/Roadmap) and see what we're working on now by looking through [Milestones](https://github.com/sourcelair/xterm.js/milestones). +### Beta builds + +Our CI releases beta builds to npm for every change that goes into master, install the latest beta build with: + +``` +npm install -S xterm@beta +``` + +These should generally be stable but some bugs may slip in, we recommend using the beta build primarily to test out new features and for verifying bug fixes. + ## Contributing You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Contributing) to learn how to contribute and setup xterm.js for development. @@ -176,6 +187,6 @@ You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Co 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) 2017-2019, [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/azure-pipelines.yml b/azure-pipelines.yml index a26bd3d84..db74a4ea6 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -66,3 +66,28 @@ jobs: - script: | yarn lint displayName: 'Lint' + +- job: Release + dependsOn: + - Linux + - macOS + - Windows + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master')) + pool: + vmImage: 'ubuntu-16.04' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '8.x' + displayName: 'Install Node.js' + - task: geeklearningio.gl-vsts-tasks-yarn.yarn-installer-task.YarnInstaller@2 + inputs: + versionSpec: "1.9.4" + displayName: 'Install Yarn' + - script: | + yarn + BUILD_DIR=dist npm run build + displayName: 'Install dependencies and build' + - script: | + NPM_AUTH_TOKEN="$(NPM_AUTH_TOKEN)" node ./bin/publish.js + displayName: 'Publish to npm' diff --git a/bin/publish.js b/bin/publish.js new file mode 100644 index 000000000..3b13d6a5a --- /dev/null +++ b/bin/publish.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const packageJson = require('../package.json'); + +// Setup auth +fs.writeFileSync(`${process.env['HOME']}/.npmrc`, `//registry.npmjs.org/:_authToken=${process.env['NPM_AUTH_TOKEN']}`); + +// Determine if this is a stable or beta release +const publishedVersions = getPublishedVersions(); +const isStableRelease = publishedVersions.indexOf(packageJson.version) === -1; + +// Get the next version +let nextVersion = isStableRelease ? packageJson.version : getNextBetaVersion(); +console.log(`Publishing version: ${nextVersion}`); + +// Set the version in package.json +const packageJsonFile = path.resolve(__dirname, '..', 'package.json'); +packageJson.version = nextVersion; +fs.writeFileSync(packageJsonFile, JSON.stringify(packageJson, null, 2)); + +// Publish +const args = ['publish']; +if (!isStableRelease) { + args.push('--tag', 'beta'); +} +const result = cp.spawn('npm', args, { stdio: 'inherit' }); +result.on('exit', code => process.exit(code)); + +function getNextBetaVersion() { + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.exec(packageJson.version)) { + console.error('The package.json version must be of the form x.y.z'); + process.exit(1); + } + const tag = 'beta'; + const stableVersion = packageJson.version.split('.'); + const nextStableVersion = `${stableVersion[0]}.${parseInt(stableVersion[1]) + 1}.${stableVersion[2]}`; + const publishedVersions = getPublishedVersions(nextStableVersion, tag); + if (publishedVersions.length === 0) { + return `${nextStableVersion}-${tag}1`; + } + const latestPublishedVersion = publishedVersions.sort((a, b) => { + const aVersion = parseInt(a.substr(a.search(/[0-9]+$/))); + const bVersion = parseInt(b.substr(b.search(/[0-9]+$/))); + return aVersion > bVersion ? -1 : 1; + })[0]; + const latestTagVersion = parseInt(latestPublishedVersion.substr(latestPublishedVersion.search(/[0-9]+$/)), 10); + return `${nextStableVersion}-${tag}${latestTagVersion + 1}`; +} + +function getPublishedVersions(version, tag) { + const versionsProcess = cp.spawnSync('npm', ['view', 'xterm', 'versions', '--json']); + const versionsJson = JSON.parse(versionsProcess.stdout); + if (tag) { + return versionsJson.filter(v => !v.search(new RegExp(`${version}-${tag}[0-9]+`))); + } + return versionsJson; +} diff --git a/demo/client.ts b/demo/client.ts index 7a6018984..90a913d7d 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -86,7 +86,7 @@ function createTerminal(): void { windowsMode: isWindows } as ITerminalOptions); window.term = term; // Expose `term` to window for debugging purposes - term.on('resize', (size: { cols: number, rows: number }) => { + term.onResize((size: { cols: number, rows: number }) => { if (!pid) { return; } @@ -165,8 +165,9 @@ function runFakeTerminal(): void { term.writeln(''); term.prompt(); - term._core.register(term.addDisposableListener('key', (key, ev) => { - const printable = !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey; + term.onKey((e: { key: string, domEvent: KeyboardEvent }) => { + const ev = e.domEvent; + const printable = !ev.altKey && !ev.ctrlKey && !ev.metaKey; if (ev.keyCode === 13) { term.prompt(); @@ -176,13 +177,9 @@ function runFakeTerminal(): void { term.write('\b \b'); } } else if (printable) { - term.write(key); + term.write(e.key); } - })); - - term._core.register(term.addDisposableListener('paste', (data, ev) => { - term.write(data); - })); + }); } function initOptions(term: TerminalType): void { @@ -262,8 +259,11 @@ function initOptions(term: TerminalType): void { console.log('change', o, input.value); if (o === 'cols' || o === 'rows') { updateTerminalSize(); + } else if (o === 'lineHeight') { + term.setOption(o, parseFloat(input.value)); + updateTerminalSize(); } else { - term.setOption(o, o === 'lineHeight' ? parseFloat(input.value) : parseInt(input.value, 10)); + term.setOption(o, parseInt(input.value)); } }); }); diff --git a/package.json b/package.json index 539b96daf..1727af82e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "3.12.0", + "version": "3.13.0", "main": "lib/public/Terminal.js", "types": "typings/xterm.d.ts", "repository": "https://github.com/xtermjs/xterm.js", @@ -57,7 +57,8 @@ "mocha": "gulp test", "prebuild": "tsc -b ./src/tsconfig.all.json", "build": "gulp build", - "prepublish": "npm run build", + "prepare": "npm run prebuild", + "prepublishOnly": "npm run build", "coveralls": "nyc report --reporter=text-lcov | coveralls", "watch": "tsc -b -w ./src/tsconfig.all.json --preserveWatchOutput" } diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index 877676c97..1f44c1321 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -61,7 +61,7 @@ export class AccessibilityManager extends Disposable { this._refreshRowsDimensions(); this._accessibilityTreeRoot.appendChild(this._rowContainer); - this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._renderRowsDebouncer = new RenderDebouncer(this._renderRows.bind(this)); this._refreshRows(); this._liveRegion = document.createElement('div'); @@ -72,20 +72,20 @@ export class AccessibilityManager extends Disposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot); this.register(this._renderRowsDebouncer); - this.register(this._terminal.addDisposableListener('resize', data => this._onResize(data.rows))); - this.register(this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end))); - this.register(this._terminal.addDisposableListener('scroll', data => this._refreshRows())); + this.register(this._terminal.onResize(e => this._onResize(e.rows))); + this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end))); + this.register(this._terminal.onScroll(() => this._refreshRows())); // Line feed is an issue as the prompt won't be read out after a command is run this.register(this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char))); - this.register(this._terminal.addDisposableListener('linefeed', () => this._onChar('\n'))); + this.register(this._terminal.onLineFeed(() => 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.onKey(e => this._onKey(e.key))); 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.register(this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions())); - this.register(this._terminal.renderer.addDisposableListener('resize', () => this._refreshRowsDimensions())); + this.register(this._terminal.renderer.onCanvasResize(() => 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.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions())); @@ -239,7 +239,7 @@ export class AccessibilityManager extends Disposable { } private _refreshRows(start?: number, end?: number): void { - this._renderRowsDebouncer.refresh(start, end); + this._renderRowsDebouncer.refresh(start, end, this._terminal.rows); } private _renderRows(start: number, end: number): void { diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 6cd6c4134..d866fd569 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -7,7 +7,7 @@ import { assert, expect } from 'chai'; import { ITerminal } from './Types'; import { Buffer, DEFAULT_ATTR_DATA } from './Buffer'; import { CircularList } from './common/CircularList'; -import { MockTerminal, TestTerminal } from './ui/TestUtils.test'; +import { MockTerminal, TestTerminal } from './TestUtils.test'; import { BufferLine, CellData } from './BufferLine'; const INIT_COLS = 80; @@ -1074,7 +1074,7 @@ describe('Buffer', () => { buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); assert.equal(marker.line, buffer.lines.length - 1); - buffer.lines.emit('trim', 1); + buffer.lines.onTrimEmitter.fire(1); assert.equal(marker.line, buffer.lines.length - 2); }); it('should dispose of a marker if it is trimmed off the buffer', () => { @@ -1085,7 +1085,7 @@ describe('Buffer', () => { const marker = buffer.addMarker(0); assert.equal(marker.isDisposed, false); assert.equal(buffer.markers.length, 1); - buffer.lines.emit('trim', 1); + buffer.lines.onTrimEmitter.fire(1); assert.equal(marker.isDisposed, true); assert.equal(buffer.markers.length, 0); }); diff --git a/src/Buffer.ts b/src/Buffer.ts index 2dbe20d62..c1c08c85a 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -3,14 +3,14 @@ * @license MIT */ -import { CircularList, IInsertEvent, IDeleteEvent } from './common/CircularList'; +import { CircularList, IInsertEvent } from './common/CircularList'; import { ITerminal, IBuffer, IBufferLine, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult, ICellData, IAttributeData } from './Types'; -import { EventEmitter } from './common/EventEmitter'; import { IMarker } from 'xterm'; import { BufferLine, CellData, AttributeData } from './BufferLine'; import { reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths, getWrappedLineTrimmedLength } from './BufferReflow'; import { DEFAULT_COLOR } from './renderer/atlas/Types'; - +import { EventEmitter2, IEvent } from './common/EventEmitter2'; +import { Disposable } from '../lib/common/Lifecycle'; export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); @@ -455,7 +455,7 @@ export class Buffer implements IBuffer { insertEvents.push({ index: originalLineIndex + 1, amount: nextToInsert.newLines.length - } as IInsertEvent); + }); countInsertedSoFar += nextToInsert.newLines.length; nextToInsert = toInsert[++nextToInsertIndex]; @@ -468,12 +468,12 @@ export class Buffer implements IBuffer { let insertCountEmitted = 0; for (let i = insertEvents.length - 1; i >= 0; i--) { insertEvents[i].index += insertCountEmitted; - this.lines.emit('insert', insertEvents[i]); + this.lines.onInsertEmitter.fire(insertEvents[i]); insertCountEmitted += insertEvents[i].amount; } const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength); if (amountToTrim > 0) { - this.lines.emitMayRemoveListeners('trim', amountToTrim); + this.lines.onTrimEmitter.fire(amountToTrim); } } } @@ -593,19 +593,19 @@ export class Buffer implements IBuffer { public addMarker(y: number): Marker { const marker = new Marker(y); this.markers.push(marker); - marker.register(this.lines.addDisposableListener('trim', amount => { + marker.register(this.lines.onTrim(amount => { marker.line -= amount; // The marker should be disposed when the line is trimmed from the buffer if (marker.line < 0) { marker.dispose(); } })); - marker.register(this.lines.addDisposableListener('insert', (event: IInsertEvent) => { + marker.register(this.lines.onInsert(event => { if (marker.line >= event.index) { marker.line += event.amount; } })); - marker.register(this.lines.addDisposableListener('delete', (event: IDeleteEvent) => { + marker.register(this.lines.onDelete(event => { // Delete the marker if it's within the range if (marker.line >= event.index && marker.line < event.index + event.amount) { marker.dispose(); @@ -616,7 +616,7 @@ export class Buffer implements IBuffer { marker.line -= event.amount; } })); - marker.register(marker.addDisposableListener('dispose', () => this._removeMarker(marker))); + marker.register(marker.onDispose(() => this._removeMarker(marker))); return marker; } @@ -629,7 +629,7 @@ export class Buffer implements IBuffer { } } -export class Marker extends EventEmitter implements IMarker { +export class Marker extends Disposable implements IMarker { private static _nextId = 1; private _id: number = Marker._nextId++; @@ -637,6 +637,9 @@ export class Marker extends EventEmitter implements IMarker { public get id(): number { return this._id; } + private _onDispose = new EventEmitter2(); + public get onDispose(): IEvent { return this._onDispose.event; } + constructor( public line: number ) { @@ -649,8 +652,7 @@ export class Marker extends EventEmitter implements IMarker { } this.isDisposed = true; // Emit before super.dispose such that dispose listeners get a change to react - this.emit('dispose'); - super.dispose(); + this._onDispose.fire(); } } diff --git a/src/BufferReflow.ts b/src/BufferReflow.ts index 9247ed660..40e16c744 100644 --- a/src/BufferReflow.ts +++ b/src/BufferReflow.ts @@ -4,7 +4,7 @@ */ import { BufferLine } from './BufferLine'; -import { CircularList, IDeleteEvent } from './common/CircularList'; +import { CircularList } from './common/CircularList'; import { IBufferLine, ICellData } from './Types'; export interface INewLayoutResult { @@ -118,10 +118,10 @@ export function reflowLargerCreateNewLayout(lines: CircularList, to const countToRemove = toRemove[++nextToRemoveIndex]; // Tell markers that there was a deletion - lines.emit('delete', { + lines.onDeleteEmitter.fire({ index: i - countRemovedSoFar, amount: countToRemove - } as IDeleteEvent); + }); i += countToRemove - 1; countRemovedSoFar += countToRemove; diff --git a/src/BufferSet.test.ts b/src/BufferSet.test.ts index 576a8ca47..cdc220b3f 100644 --- a/src/BufferSet.test.ts +++ b/src/BufferSet.test.ts @@ -7,7 +7,7 @@ import { assert } from 'chai'; import { ITerminal } from './Types'; import { BufferSet } from './BufferSet'; import { Buffer } from './Buffer'; -import { MockTerminal } from './ui/TestUtils.test'; +import { MockTerminal } from './TestUtils.test'; describe('BufferSet', () => { let terminal: ITerminal; diff --git a/src/BufferSet.ts b/src/BufferSet.ts index 268c2d88b..ba885a0e9 100644 --- a/src/BufferSet.ts +++ b/src/BufferSet.ts @@ -3,25 +3,28 @@ * @license MIT */ -import { ITerminal, IBufferSet, IAttributeData } from './Types'; +import { ITerminal, IBufferSet, IAttributeData, IBuffer } from './Types'; import { Buffer } from './Buffer'; -import { EventEmitter } from './common/EventEmitter'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; /** * The BufferSet represents the set of two buffers used by xterm terminals (normal and alt) and * provides also utilities for working with them. */ -export class BufferSet extends EventEmitter implements IBufferSet { +export class BufferSet implements IBufferSet { private _normal: Buffer; private _alt: Buffer; private _activeBuffer: Buffer; + + private _onBufferActivate = new EventEmitter2<{activeBuffer: IBuffer, inactiveBuffer: IBuffer}>(); + public get onBufferActivate(): IEvent<{activeBuffer: IBuffer, inactiveBuffer: IBuffer}> { return this._onBufferActivate.event; } + /** * Create a new BufferSet for the given terminal. * @param _terminal - The terminal the BufferSet will belong to */ constructor(private _terminal: ITerminal) { - super(); this._normal = new Buffer(this._terminal, true); this._normal.fillViewportRows(); @@ -68,7 +71,7 @@ export class BufferSet extends EventEmitter implements IBufferSet { // when activated. this._alt.clear(); this._activeBuffer = this._normal; - this.emit('activate', { + this._onBufferActivate.fire({ activeBuffer: this._normal, inactiveBuffer: this._alt }); @@ -87,7 +90,7 @@ export class BufferSet extends EventEmitter implements IBufferSet { this._alt.x = this._normal.x; this._alt.y = this._normal.y; this._activeBuffer = this._alt; - this.emit('activate', { + this._onBufferActivate.fire({ activeBuffer: this._alt, inactiveBuffer: this._normal }); diff --git a/src/ui/CharMeasure.test.ts b/src/CharMeasure.test.ts similarity index 97% rename from src/ui/CharMeasure.test.ts rename to src/CharMeasure.test.ts index a3cb3b3b4..5fd17eb24 100644 --- a/src/ui/CharMeasure.test.ts +++ b/src/CharMeasure.test.ts @@ -4,7 +4,7 @@ */ import jsdom = require('jsdom'); -import { ICharMeasure } from '../Types'; +import { ICharMeasure } from './Types'; import { assert } from 'chai'; import { CharMeasure } from './CharMeasure'; diff --git a/src/ui/CharMeasure.ts b/src/CharMeasure.ts similarity index 82% rename from src/ui/CharMeasure.ts rename to src/CharMeasure.ts index 0ac755eab..efee89729 100644 --- a/src/ui/CharMeasure.ts +++ b/src/CharMeasure.ts @@ -3,23 +3,25 @@ * @license MIT */ -import { ICharMeasure, ITerminalOptions } from '../Types'; -import { EventEmitter } from '../common/EventEmitter'; +import { ICharMeasure, ITerminalOptions } from './Types'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; /** * Utility class that measures the size of a character. Measurements are done in * the DOM rather than with a canvas context because support for extracting the * height of characters is patchy across browsers. */ -export class CharMeasure extends EventEmitter implements ICharMeasure { +export class CharMeasure implements ICharMeasure { private _document: Document; private _parentElement: HTMLElement; private _measureElement: HTMLElement; private _width: number; private _height: number; + private _onCharSizeChanged = new EventEmitter2(); + public get onCharSizeChanged(): IEvent { return this._onCharSizeChanged.event; } + constructor(document: Document, parentElement: HTMLElement) { - super(); this._document = document; this._parentElement = parentElement; this._measureElement = this._document.createElement('span'); @@ -50,7 +52,7 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { if (this._width !== geometry.width || this._height !== adjustedHeight) { this._width = geometry.width; this._height = adjustedHeight; - this.emit('charsizechanged'); + this._onCharSizeChanged.fire(); } } } diff --git a/src/CharWidth.test.ts b/src/CharWidth.test.ts index 8608c6fa3..ff6f17ede 100644 --- a/src/CharWidth.test.ts +++ b/src/CharWidth.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { TestTerminal } from './ui/TestUtils.test'; +import { TestTerminal } from './TestUtils.test'; import { assert } from 'chai'; import { getStringCellWidth, wcwidth } from './CharWidth'; import { IBuffer } from './Types'; diff --git a/src/ui/Clipboard.test.ts b/src/Clipboard.test.ts similarity index 100% rename from src/ui/Clipboard.test.ts rename to src/Clipboard.test.ts diff --git a/src/ui/Clipboard.ts b/src/Clipboard.ts similarity index 98% rename from src/ui/Clipboard.ts rename to src/Clipboard.ts index 2570a8b17..7a32badcd 100644 --- a/src/ui/Clipboard.ts +++ b/src/Clipboard.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ITerminal, ISelectionManager } from '../Types'; +import { ITerminal, ISelectionManager } from './Types'; interface IWindow extends Window { clipboardData?: { diff --git a/src/EscapeSequenceParser.ts b/src/EscapeSequenceParser.ts index 7b65624d2..197a36570 100644 --- a/src/EscapeSequenceParser.ts +++ b/src/EscapeSequenceParser.ts @@ -353,7 +353,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP if (this._oscHandlers[ident] === undefined) { this._oscHandlers[ident] = []; } - const handlerList = this._oscHandlers[ident]; + const handlerList = this._oscHandlers[ident]; handlerList.push(callback); return { dispose: () => { @@ -505,7 +505,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP const handlers = this._csiHandlers[code]; let j = handlers ? handlers.length - 1 : -1; for (; j >= 0; j--) { - if (handlers[j](params, collect)) { + // undefined or true means success and to stop bubbling + if (handlers[j](params, collect) !== false) { break; } } @@ -590,7 +591,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP const handlers = this._oscHandlers[identifier]; let j = handlers ? handlers.length - 1 : -1; for (; j >= 0; j--) { - if (handlers[j](content)) { + // undefined or true means success and to stop bubbling + if (handlers[j](content) !== false) { break; } } diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index 01596f808..dcd4d1b46 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -5,7 +5,7 @@ import { assert, expect } from 'chai'; import { InputHandler } from './InputHandler'; -import { MockInputHandlingTerminal, TestTerminal } from './ui/TestUtils.test'; +import { MockInputHandlingTerminal, TestTerminal } from './TestUtils.test'; import { DEFAULT_ATTR_DATA } from './Buffer'; import { Terminal } from './Terminal'; import { IBufferLine } from './Types'; diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 95eab4a33..66d16c04c 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -15,6 +15,7 @@ import { Disposable } from './common/Lifecycle'; import { concat } from './common/TypedArrayUtils'; import { StringToUtf32, stringFromCodePoint, utf32ToString } from './core/input/TextDecoder'; import { CellData, Attributes, FgFlags, BgFlags, AttributeData } from './BufferLine'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; /** * Map collect to glevel. Used in `selectCharset`. @@ -106,6 +107,15 @@ export class InputHandler extends Disposable implements IInputHandler { private _stringDecoder: StringToUtf32 = new StringToUtf32(); private _workCell: CellData = new CellData(); + private _onCursorMove = new EventEmitter2(); + public get onCursorMove(): IEvent { return this._onCursorMove.event; } + private _onData = new EventEmitter2(); + public get onData(): IEvent { return this._onData.event; } + private _onLineFeed = new EventEmitter2(); + public get onLineFeed(): IEvent { return this._onLineFeed.event; } + private _onScroll = new EventEmitter2(); + public get onScroll(): IEvent { return this._onScroll.event; } + constructor( protected _terminal: IInputHandlingTerminal, private _parser: IEscapeSequenceParser = new EscapeSequenceParser()) @@ -305,7 +315,7 @@ export class InputHandler extends Disposable implements IInputHandler { buffer = this._terminal.buffer; if (buffer.x !== cursorStartX || buffer.y !== cursorStartY) { - this._terminal.emit('cursormove'); + this._onCursorMove.fire(); } } @@ -419,10 +429,17 @@ export class InputHandler extends Disposable implements IInputHandler { this._terminal.updateRange(buffer.y); } - addCsiHandler(flag: string, callback: (params: number[], collect: string) => boolean): IDisposable { + /** + * Forward addCsiHandler from parser. + */ + public addCsiHandler(flag: string, callback: (params: number[], collect: string) => boolean): IDisposable { return this._parser.addCsiHandler(flag, callback); } - addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { + + /** + * Forward addOscHandler from parser. + */ + public addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { return this._parser.addOscHandler(ident, callback); } @@ -454,12 +471,8 @@ export class InputHandler extends Disposable implements IInputHandler { if (buffer.x >= this._terminal.cols) { buffer.x--; } - /** - * This event is emitted whenever the terminal outputs a LF or NL. - * - * @event linefeed - */ - this._terminal.emit('linefeed'); + + this._onLineFeed.fire(); } /** @@ -762,7 +775,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._terminal.buffer.ybase = Math.max(this._terminal.buffer.ybase - scrollBackSize, 0); this._terminal.buffer.ydisp = Math.max(this._terminal.buffer.ydisp - scrollBackSize, 0); // Force a scroll event to refresh viewport - this._terminal.emit('scroll', 0); + this._onScroll.fire(0); } break; } @@ -1721,13 +1734,13 @@ export class InputHandler extends Disposable implements IInputHandler { switch (params[0]) { case 5: // status report - this._terminal.emit('data', `${C0.ESC}[0n`); + this._onData.fire(`${C0.ESC}[0n`); break; case 6: // cursor position const y = this._terminal.buffer.y + 1; const x = this._terminal.buffer.x + 1; - this._terminal.emit('data', `${C0.ESC}[${y};${x}R`); + this._onData.fire(`${C0.ESC}[${y};${x}R`); break; } } else if (collect === '?') { @@ -1738,7 +1751,7 @@ export class InputHandler extends Disposable implements IInputHandler { // cursor position const y = this._terminal.buffer.y + 1; const x = this._terminal.buffer.x + 1; - this._terminal.emit('data', `${C0.ESC}[?${y};${x}R`); + this._onData.fire(`${C0.ESC}[?${y};${x}R`); break; case 15: // no printer @@ -1824,7 +1837,9 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI ? Pm r */ public setScrollRegion(params: number[], collect?: string): void { - if (collect) return; + if (collect) { + return; + } this._terminal.buffer.scrollTop = (params[0] || 1) - 1; this._terminal.buffer.scrollBottom = (params[1] && params[1] <= this._terminal.rows ? params[1] : this._terminal.rows) - 1; this._terminal.buffer.x = 0; @@ -1932,9 +1947,15 @@ export class InputHandler extends Disposable implements IInputHandler { * Designate G3 Character Set (VT300). C = A -> ISO Latin-1 Supplemental. - Supported? */ public selectCharset(collectAndFlag: string): void { - if (collectAndFlag.length !== 2) return this.selectDefaultCharset(); - if (collectAndFlag[0] === '/') return; // TODO: Is this supported? + if (collectAndFlag.length !== 2) { + this.selectDefaultCharset(); + return; + } + if (collectAndFlag[0] === '/') { + return; // TODO: Is this supported? + } this._terminal.setgCharset(GLEVEL[collectAndFlag[0]], CHARSETS[collectAndFlag[1]] || DEFAULT_CHARSET); + return; } /** diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index c7bbbeb83..8f734bacb 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -4,10 +4,9 @@ */ import { assert } from 'chai'; -import { IMouseZoneManager, IMouseZone } from './ui/Types'; -import { ILinkMatcher, ITerminal, IBufferLine } from './Types'; +import { IMouseZoneManager, IMouseZone, ILinkMatcher, ITerminal, IBufferLine } from './Types'; import { Linkifier } from './Linkifier'; -import { MockBuffer, MockTerminal, TestTerminal } from './ui/TestUtils.test'; +import { MockBuffer, MockTerminal, TestTerminal } from './TestUtils.test'; import { CircularList } from './common/CircularList'; import { BufferLine, CellData } from './BufferLine'; diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 80399904c..9744929e1 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -3,16 +3,15 @@ * @license MIT */ -import { IMouseZoneManager } from './ui/Types'; -import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal, IBufferStringIteratorResult } from './Types'; -import { MouseZone } from './ui/MouseZoneManager'; -import { EventEmitter } from './common/EventEmitter'; +import { ILinkifierEvent, ILinkMatcher, LinkMatcherHandler, ILinkMatcherOptions, ILinkifier, ITerminal, IBufferStringIteratorResult, IMouseZoneManager } from './Types'; +import { MouseZone } from './MouseZoneManager'; import { getStringCellWidth } from './CharWidth'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; /** * The Linkifier applies links to rows shortly after they have been refreshed. */ -export class Linkifier extends EventEmitter implements ILinkifier { +export class Linkifier implements ILinkifier { /** * The time to wait after a row is changed before it is linkified. This prevents * the costly operation of searching every row multiple times, potentially a @@ -34,10 +33,16 @@ export class Linkifier extends EventEmitter implements ILinkifier { private _nextLinkMatcherId = 0; private _rowsToLinkify: { start: number, end: number }; + private _onLinkHover = new EventEmitter2(); + public get onLinkHover(): IEvent { return this._onLinkHover.event; } + private _onLinkLeave = new EventEmitter2(); + public get onLinkLeave(): IEvent { return this._onLinkLeave.event; } + private _onLinkTooltip = new EventEmitter2(); + public get onLinkTooltip(): IEvent { return this._onLinkTooltip.event; } + constructor( protected _terminal: ITerminal ) { - super(); this._rowsToLinkify = { start: null, end: null @@ -283,18 +288,18 @@ export class Linkifier extends EventEmitter implements ILinkifier { } window.open(uri, '_blank'); }, - e => { - this.emit(LinkHoverEventTypes.HOVER, this._createLinkHoverEvent(x1, y1, x2, y2, fg)); + () => { + this._onLinkHover.fire(this._createLinkHoverEvent(x1, y1, x2, y2, fg)); this._terminal.element.classList.add('xterm-cursor-pointer'); }, e => { - this.emit(LinkHoverEventTypes.TOOLTIP, this._createLinkHoverEvent(x1, y1, x2, y2, fg)); + this._onLinkTooltip.fire(this._createLinkHoverEvent(x1, y1, x2, y2, fg)); if (matcher.hoverTooltipCallback) { matcher.hoverTooltipCallback(e, uri); } }, () => { - this.emit(LinkHoverEventTypes.LEAVE, this._createLinkHoverEvent(x1, y1, x2, y2, fg)); + this._onLinkLeave.fire(this._createLinkHoverEvent(x1, y1, x2, y2, fg)); this._terminal.element.classList.remove('xterm-cursor-pointer'); if (matcher.hoverLeaveCallback) { matcher.hoverLeaveCallback(); @@ -309,7 +314,7 @@ export class Linkifier extends EventEmitter implements ILinkifier { )); } - private _createLinkHoverEvent(x1: number, y1: number, x2: number, y2: number, fg: number): ILinkHoverEvent { + private _createLinkHoverEvent(x1: number, y1: number, x2: number, y2: number, fg: number): ILinkifierEvent { return { x1, y1, x2, y2, cols: this._terminal.cols, fg }; } } diff --git a/src/ui/MouseHelper.test.ts b/src/MouseHelper.test.ts similarity index 100% rename from src/ui/MouseHelper.test.ts rename to src/MouseHelper.test.ts diff --git a/src/ui/MouseHelper.ts b/src/MouseHelper.ts similarity index 96% rename from src/ui/MouseHelper.ts rename to src/MouseHelper.ts index e36e7f17c..fb91c9ef8 100644 --- a/src/ui/MouseHelper.ts +++ b/src/MouseHelper.ts @@ -3,8 +3,8 @@ * @license MIT */ -import { ICharMeasure, IMouseHelper } from '../Types'; -import { IRenderer } from '../renderer/Types'; +import { ICharMeasure, IMouseHelper } from './Types'; +import { IRenderer } from './renderer/Types'; export class MouseHelper implements IMouseHelper { constructor(private _renderer: IRenderer) {} diff --git a/src/ui/MouseZoneManager.ts b/src/MouseZoneManager.ts similarity index 97% rename from src/ui/MouseZoneManager.ts rename to src/MouseZoneManager.ts index 372dccc57..2f293df14 100644 --- a/src/ui/MouseZoneManager.ts +++ b/src/MouseZoneManager.ts @@ -3,10 +3,9 @@ * @license MIT */ -import { ITerminal } from '../Types'; -import { IMouseZoneManager, IMouseZone } from './Types'; -import { Disposable } from '../common/Lifecycle'; -import { addDisposableDomListener } from './Lifecycle'; +import { ITerminal, IMouseZoneManager, IMouseZone } from './Types'; +import { Disposable } from './common/Lifecycle'; +import { addDisposableDomListener } from './ui/Lifecycle'; const HOVER_DURATION = 500; diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index d65f97160..9a2ac4051 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -4,12 +4,12 @@ */ import { assert } from 'chai'; -import { CharMeasure } from './ui/CharMeasure'; +import { CharMeasure } from './CharMeasure'; import { SelectionManager, SelectionMode } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; import { ITerminal, IBuffer, IBufferLine } from './Types'; -import { MockTerminal } from './ui/TestUtils.test'; +import { MockTerminal } from './TestUtils.test'; import { BufferLine, CellData } from './BufferLine'; class TestMockTerminal extends MockTerminal { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 394808f61..dcd600687 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -3,15 +3,15 @@ * @license MIT */ -import { ITerminal, ISelectionManager, IBuffer, IBufferLine } from './Types'; -import { XtermListener } from './common/Types'; -import { MouseHelper } from './ui/MouseHelper'; +import { ITerminal, ISelectionManager, IBuffer, IBufferLine, ISelectionRedrawRequestEvent } from './Types'; +import { MouseHelper } from './MouseHelper'; import * as Browser from './common/Platform'; -import { CharMeasure } from './ui/CharMeasure'; -import { EventEmitter } from './common/EventEmitter'; +import { CharMeasure } from './CharMeasure'; import { SelectionModel } from './SelectionModel'; import { AltClickHandler } from './handlers/AltClickHandler'; import { CellData } from './BufferLine'; +import { IDisposable } from 'xterm'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -67,10 +67,10 @@ export const enum SelectionMode { * SelectionModel, SelectionManager handles with all logic associated with * dealing with the selection, including handling mouse interaction, wide * characters and fetching the actual text within the selection. Rendering is - * not handled by the SelectionManager but a 'refresh' event is fired when the - * selection is ready to be redrawn. + * not handled by the SelectionManager but the onRedrawRequest event is fired + * when the selection is ready to be redrawn (on an animation frame). */ -export class SelectionManager extends EventEmitter implements ISelectionManager { +export class SelectionManager implements ISelectionManager { protected _model: SelectionModel; /** @@ -102,16 +102,22 @@ export class SelectionManager extends EventEmitter implements ISelectionManager private _mouseMoveListener: EventListener; private _mouseUpListener: EventListener; - private _trimListener: XtermListener; + private _trimListener: IDisposable; private _workCell: CellData = new CellData(); private _mouseDownTimeStamp: number; + private _onLinuxMouseSelection = new EventEmitter2(); + public get onLinuxMouseSelection(): IEvent { return this._onLinuxMouseSelection.event; } + private _onRedrawRequest = new EventEmitter2(); + public get onRedrawRequest(): IEvent { return this._onRedrawRequest.event; } + private _onSelectionChange = new EventEmitter2(); + public get onSelectionChange(): IEvent { return this._onSelectionChange.event; } + constructor( private _terminal: ITerminal, private _charMeasure: CharMeasure ) { - super(); this._initListeners(); this.enable(); @@ -120,7 +126,6 @@ export class SelectionManager extends EventEmitter implements ISelectionManager } public dispose(): void { - super.dispose(); this._removeMouseDownListeners(); } @@ -134,14 +139,13 @@ export class SelectionManager extends EventEmitter implements ISelectionManager private _initListeners(): void { this._mouseMoveListener = event => this._onMouseMove(event); this._mouseUpListener = event => this._onMouseUp(event); - this._trimListener = (amount: number) => this._onTrim(amount); this.initBuffersListeners(); } public initBuffersListeners(): void { - this._terminal.buffer.lines.on('trim', this._trimListener); - this._terminal.buffers.on('activate', e => this._onBufferActivate(e)); + this._trimListener = this._terminal.buffer.lines.onTrim(amount => this._onTrim(amount)); + this._terminal.buffers.onBufferActivate(e => this._onBufferActivate(e)); } /** @@ -245,10 +249,10 @@ export class SelectionManager extends EventEmitter implements ISelectionManager /** * Queues a refresh, redrawing the selection on the next opportunity. - * @param isNewSelection Whether the selection should be registered as a new + * @param isLinuxMouseSelection Whether the selection should be registered as a new * selection on Linux. */ - public refresh(isNewSelection?: boolean): void { + public refresh(isLinuxMouseSelection?: boolean): void { // Queue the refresh for the renderer if (!this._refreshAnimationFrame) { this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh()); @@ -256,10 +260,10 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // If the platform is Linux and the refresh call comes from a mouse event, // we need to update the selection for middle click to paste selection. - if (Browser.isLinux && isNewSelection) { + if (Browser.isLinux && isLinuxMouseSelection) { const selectionText = this.selectionText; if (selectionText.length) { - this.emit('newselection', this.selectionText); + this._onLinuxMouseSelection.fire(this.selectionText); } } } @@ -270,7 +274,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager */ private _refresh(): void { this._refreshAnimationFrame = null; - this.emit('refresh', { + this._onRedrawRequest.fire({ start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd, columnSelectMode: this._activeSelectionMode === SelectionMode.COLUMN @@ -319,7 +323,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager public selectAll(): void { this._model.isSelectAllActive = true; this.refresh(); - this._terminal.emit('selection'); + this._onSelectionChange.fire(); } public selectLines(start: number, end: number): void { @@ -329,7 +333,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager this._model.selectionStart = [0, start]; this._model.selectionEnd = [this._terminal.cols, end]; this.refresh(); - this._terminal.emit('selection'); + this._onSelectionChange.fire(); } /** @@ -647,7 +651,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME) { (new AltClickHandler(event, this._terminal)).move(); } else if (this.hasSelection) { - this._terminal.emit('selection'); + this._onSelectionChange.fire(); } } @@ -657,8 +661,10 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // reverseIndex) and delete in a splice is only ever used when the same // number of elements was just added. Given this is could actually be // beneficial to leave the selection as is for these cases. - e.inactiveBuffer.lines.off('trim', this._trimListener); - e.activeBuffer.lines.on('trim', this._trimListener); + if (this._trimListener) { + this._trimListener.dispose(); + } + this._trimListener = e.activeBuffer.lines.onTrim(amount => this._onTrim(amount)); } /** diff --git a/src/SelectionModel.test.ts b/src/SelectionModel.test.ts index d49f41d0c..c2b261a98 100644 --- a/src/SelectionModel.test.ts +++ b/src/SelectionModel.test.ts @@ -7,7 +7,7 @@ import { assert } from 'chai'; import { ITerminal } from './Types'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; -import { MockTerminal } from './ui/TestUtils.test'; +import { MockTerminal } from './TestUtils.test'; class TestSelectionModel extends SelectionModel { constructor( diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index f4b717d8b..69fbb8e2e 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -5,7 +5,7 @@ import { assert, expect } from 'chai'; import { Terminal } from './Terminal'; -import { MockViewport, MockCompositionHelper, MockRenderer } from './ui/TestUtils.test'; +import { MockViewport, MockCompositionHelper, MockRenderer } from './TestUtils.test'; import { DEFAULT_ATTR_DATA } from './Buffer'; import { CellData } from './BufferLine'; @@ -17,7 +17,7 @@ class TestTerminal extends Terminal { public keyPress(ev: any): boolean { return this._keyPress(ev); } } -describe('term.js addons', () => { +describe('xterm.js', () => { let term: TestTerminal; const termOptions = { cols: INIT_COLS, @@ -68,6 +68,81 @@ describe('term.js addons', () => { }); }); + describe('events', () => { + it('should fire the onData evnet', (done) => { + term.onData(() => done()); + term.handler('fake'); + }); + it('should fire the onCursorMove event', (done) => { + term.on('cursormove', () => done()); + term.write('foo'); + }); + it('should fire the onLineFeed event', (done) => { + term.on('linefeed', () => done()); + term.write('\n'); + }); + it('should fire a scroll event when scrollback is created', (done) => { + term.on('scroll', () => done()); + term.write('\n'.repeat(INIT_ROWS)); + }); + it('should fire a scroll event when scrollback is cleared', (done) => { + term.write('\n'.repeat(INIT_ROWS)); + term.on('scroll', () => done()); + term.clear(); + }); + it('should fire a key event after a keypress DOM event', (done) => { + term.onKey(e => { + assert.equal(typeof e.key, 'string'); + expect(e.domEvent).to.be.an.instanceof(Object); + done(); + }); + const evKeyPress = { + preventDefault: () => { }, + stopPropagation: () => { }, + type: 'keypress', + keyCode: 13 + }; + term.keyPress(evKeyPress); + }); + it('should fire a key event after a keydown DOM event', (done) => { + term.onKey(e => { + assert.equal(typeof e.key, 'string'); + expect(e.domEvent).to.be.an.instanceof(Object); + done(); + }); + const evKeyDown = { + preventDefault: () => { }, + stopPropagation: () => { }, + type: 'keydown', + keyCode: 13 + }; + term.keyDown(evKeyDown); + }); + it('should fire the onResize event', (done) => { + term.onResize(e => { + expect(e).to.have.keys(['cols', 'rows']); + assert.equal(typeof e.cols, 'number'); + assert.equal(typeof e.rows, 'number'); + done(); + }); + term.resize(1, 1); + }); + it('should fire the onScroll event', (done) => { + term.onScroll(e => { + assert.equal(typeof e, 'number'); + done(); + }); + term.scroll(); + }); + it('should fire the onTitleChange event', (done) => { + term.onTitleChange(e => { + assert.equal(e, 'title'); + done(); + }); + term.handleTitle('title'); + }); + }); + describe('on', () => { beforeEach(() => { term.on('key', () => { }); @@ -85,6 +160,40 @@ describe('term.js addons', () => { }); }); + describe('cursormove', () => { + it('should emit a cursormove event', (done) => { + term.on('cursormove', () => { + done(); + }); + term.write('foo'); + }); + }); + + describe('linefeed', () => { + it('should emit a linefeed event', (done) => { + term.on('linefeed', () => { + done(); + }); + term.write('\n'); + }); + }); + + describe('scroll', () => { + it('should emit a scroll event when scrollback is created', (done) => { + term.on('scroll', () => { + done(); + }); + term.write('\n'.repeat(INIT_ROWS)); + }); + it('should emit a scroll event when scrollback is cleared', (done) => { + term.write('\n'.repeat(INIT_ROWS)); + term.on('scroll', () => { + done(); + }); + term.clear(); + }); + }); + describe(`keypress (including 'key' event)`, () => { it('should receive a string and event object', (done) => { let steps = 0; diff --git a/src/Terminal.ts b/src/Terminal.ts index 7eab1c88b..3fe5b5904 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -21,27 +21,26 @@ * http://linux.die.net/man/7/urxvt */ -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharacterJoinerHandler, IBufferLine, IAttributeData } from './Types'; -import { IMouseZoneManager } from './ui/Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharacterJoinerHandler, IBufferLine, IAttributeData, IMouseZoneManager } from './Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; import { Buffer, MAX_BUFFER_SIZE, DEFAULT_ATTR_DATA } from './Buffer'; import { CompositionHelper } from './CompositionHelper'; import { EventEmitter } from './common/EventEmitter'; import { Viewport } from './Viewport'; -import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './ui/Clipboard'; +import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './Clipboard'; import { C0 } from './common/data/EscapeSequences'; import { InputHandler } from './InputHandler'; import { Renderer } from './renderer/Renderer'; import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; -import { CharMeasure } from './ui/CharMeasure'; +import { CharMeasure } from './CharMeasure'; import * as Browser from './common/Platform'; import { addDisposableDomListener } from './ui/Lifecycle'; import * as Strings from './Strings'; -import { MouseHelper } from './ui/MouseHelper'; +import { MouseHelper } from './MouseHelper'; import { DEFAULT_BELL_SOUND, SoundManager } from './SoundManager'; -import { MouseZoneManager } from './ui/MouseZoneManager'; +import { MouseZoneManager } from './MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './ui/ScreenDprMonitor'; import { ITheme, IMarker, IDisposable } from 'xterm'; @@ -51,6 +50,7 @@ import { IKeyboardEvent } from './common/Types'; import { evaluateKeyboardEvent } from './core/input/Keyboard'; import { KeyboardResultType, ICharset } from './core/Types'; import { clone } from './common/Clone'; +import { EventEmitter2, IEvent } from './common/EventEmitter2'; import { Attributes } from './BufferLine'; import { applyWindowsMode } from './WindowsMode'; @@ -221,6 +221,25 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II public cols: number; public rows: number; + private _onCursorMove = new EventEmitter2(); + public get onCursorMove(): IEvent { return this._onCursorMove.event; } + private _onData = new EventEmitter2(); + public get onData(): IEvent { return this._onData.event; } + private _onKey = new EventEmitter2<{ key: string, domEvent: KeyboardEvent }>(); + public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._onKey.event; } + private _onLineFeed = new EventEmitter2(); + public get onLineFeed(): IEvent { return this._onLineFeed.event; } + private _onRender = new EventEmitter2<{ start: number, end: number }>(); + public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } + private _onResize = new EventEmitter2<{ cols: number, rows: number }>(); + public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } + private _onScroll = new EventEmitter2(); + public get onScroll(): IEvent { return this._onScroll.event; } + private _onSelectionChange = new EventEmitter2(); + public get onSelectionChange(): IEvent { return this._onSelectionChange.event; } + private _onTitleChange = new EventEmitter2(); + public get onTitleChange(): IEvent { return this._onTitleChange.event; } + /** * Creates a new `Terminal` object. * @@ -239,6 +258,18 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II super(); this.options = clone(options); this._setup(); + + // TODO: Remove these in v4 + // Fire old style events from new emitters + this.onCursorMove(() => this.emit('cursormove')); + this.onData(e => this.emit('data', e)); + this.onKey(e => this.emit('key', e.key, e.domEvent)); + this.onLineFeed(() => this.emit('linefeed')); + this.onRender(e => this.emit('refresh', e)); + this.onResize(e => this.emit('resize', e)); + this.onSelectionChange(() => this.emit('selection')); + this.onScroll(e => this.emit('scroll', e)); + this.onTitleChange(e => this.emit('title', e)); } public dispose(): void { @@ -279,7 +310,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.rows = Math.max(this.options.rows, MINIMUM_ROWS); if (this.options.handler) { - this.on('data', this.options.handler); + this.onData(this.options.handler); } this.cursorState = 0; @@ -315,8 +346,12 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // this._writeStopped = false; this._userScrolling = false; + // Register input handler and refire/handle events this._inputHandler = new InputHandler(this); + this._inputHandler.onCursorMove(() => this._onCursorMove.fire()); + this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); 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; @@ -649,8 +684,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II 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))); + this.register(this.onRender(() => this._compositionHelper.updateCompositionElements())); + this.register(this.onRender(e => this._queueLinkification(e.start, e.end))); } /** @@ -702,7 +737,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this._mouseZoneManager = new MouseZoneManager(this); this.register(this._mouseZoneManager); - this.register(this.addDisposableListener('scroll', () => this._mouseZoneManager.clearAll())); + this.register(this.onScroll(() => this._mouseZoneManager.clearAll())); this.linkifier.attachToDom(this._mouseZoneManager); this.textarea = document.createElement('textarea'); @@ -735,21 +770,22 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.viewport.onThemeChanged(this.renderer.colorManager.colors); this.register(this.viewport); - this.register(this.addDisposableListener('cursormove', () => this.renderer.onCursorMove())); - this.register(this.addDisposableListener('resize', () => this.renderer.onResize(this.cols, this.rows))); + this.register(this.onCursorMove(() => this.renderer.onCursorMove())); + this.register(this.onResize(() => 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.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.register(this.charMeasure.onCharSizeChanged(() => this.renderer.onCharSizeChanged())); + this.register(this.renderer.onCanvasResize(() => this.viewport.syncScrollArea())); this.selectionManager = new SelectionManager(this, this.charMeasure); + this.register(this.selectionManager.onSelectionChange(() => this._onSelectionChange.fire())); 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 => { + this.register(this.selectionManager.onRedrawRequest(e => this.renderer.onSelectionChanged(e.start, e.end, e.columnSelectMode))); + this.register(this.selectionManager.onLinuxMouseSelection(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. @@ -757,7 +793,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.textarea.focus(); this.textarea.select(); })); - this.register(this.addDisposableListener('scroll', () => { + this.register(this.onScroll(() => { this.viewport.syncScrollArea(); this.selectionManager.refresh(); })); @@ -799,6 +835,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II case 'dom': this.renderer = new DomRenderer(this, this.options.theme); break; default: throw new Error(`Unrecognized rendererType "${this.options.rendererType}"`); } + this.renderer.onRender(e => this._onRender.fire(e)); this.register(this.renderer); } @@ -1264,13 +1301,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.updateRange(this.buffer.scrollTop); this.updateRange(this.buffer.scrollBottom); - /** - * This event is emitted whenever the terminal is scrolled. - * The one parameter passed is the new y display position. - * - * @event scroll - */ - this.emit('scroll', this.buffer.ydisp); + this._onScroll.fire(this.buffer.ydisp); } /** @@ -1299,7 +1330,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } if (!suppressScrollEvent) { - this.emit('scroll', this.buffer.ydisp); + this._onScroll.fire(this.buffer.ydisp); } this.refresh(0, this.rows - 1); @@ -1588,7 +1619,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } this.emit('keydown', event); - this.emit('key', result.key, event); + this._onKey.fire({ key: result.key, domEvent: event }); this.showCursor(); this.handler(result.key); @@ -1667,7 +1698,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II key = String.fromCharCode(key); this.emit('keypress', key, ev); - this.emit('key', key, ev); + this._onKey.fire({ key, domEvent: ev }); this.showCursor(); this.handler(key); @@ -1744,7 +1775,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } this.refresh(0, this.rows - 1); - this.emit('resize', {cols: x, rows: y}); + this._onResize.fire({ cols: x, rows: y }); } /** @@ -1787,7 +1818,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.buffer.lines.push(this.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } this.refresh(0, this.rows - 1); - this.emit('scroll', this.buffer.ydisp); + this._onScroll.fire(this.buffer.ydisp); } /** @@ -1799,7 +1830,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } /** - * Emit the 'data' event and populate the given data. + * Emit the data event and populate the given data. * @param data The data to populate in the event. */ public handler(data: string): void { @@ -1817,7 +1848,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (this.buffer.ybase !== this.buffer.ydisp) { this.scrollToBottom(); } - this.emit('data', data); + this._onData.fire(data); } /** @@ -1825,13 +1856,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * @param title The title to populate in the event. */ public handleTitle(title: string): void { - /** - * This event is emitted when the title of the terminal is changed - * from inside the terminal. The parameter is the new title. - * - * @event title - */ - this.emit('title', title); + this._onTitleChange.fire(title); } /** diff --git a/src/ui/TestUtils.test.ts b/src/TestUtils.test.ts similarity index 93% rename from src/ui/TestUtils.test.ts rename to src/TestUtils.test.ts index d6b2d559c..92018d1f1 100644 --- a/src/ui/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -3,14 +3,14 @@ * @license MIT */ -import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Types'; -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ILinkifier, IMouseHelper, ILinkMatcherOptions, CharacterJoinerHandler, IBufferLine, IBufferStringIterator, ICellData, IAttributeData } from '../Types'; -import { ICircularList, XtermListener } from '../common/Types'; -import { Buffer } from '../Buffer'; -import * as Browser from '../common/Platform'; -import { ITheme, IDisposable, IMarker } from 'xterm'; -import { Terminal } from '../Terminal'; -import { AttributeData } from '../BufferLine'; +import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from './renderer/Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ILinkifier, IMouseHelper, ILinkMatcherOptions, CharacterJoinerHandler, IBufferLine, IBufferStringIterator, ICellData, IAttributeData } from './Types'; +import { ICircularList, XtermListener } from './common/Types'; +import { Buffer } from './Buffer'; +import * as Browser from './common/Platform'; +import { ITheme, IDisposable, IMarker, IEvent } from 'xterm'; +import { Terminal } from './Terminal'; +import { AttributeData } from './BufferLine'; export class TestTerminal extends Terminal { writeSync(data: string): void { @@ -20,6 +20,15 @@ export class TestTerminal extends Terminal { } export class MockTerminal implements ITerminal { + onCursorMove: IEvent; + onLineFeed: IEvent; + onSelectionChange: IEvent; + onData: IEvent; + onTitleChange: IEvent; + onScroll: IEvent; + onKey: IEvent<{ key: string; domEvent: KeyboardEvent; }>; + onRender: IEvent<{ start: number; end: number; }>; + onResize: IEvent<{ cols: number; rows: number; }>; markers: IMarker[]; addMarker(cursorYOffset: number): IMarker { throw new Error('Method not implemented.'); @@ -166,6 +175,7 @@ export class MockTerminal implements ITerminal { } export class MockCharMeasure implements ICharMeasure { + onCharSizeChanged: IEvent; width: number; height: number; measure(options: ITerminalOptions): void { @@ -344,6 +354,8 @@ export class MockBuffer implements IBuffer { } export class MockRenderer implements IRenderer { + onCanvasResize: IEvent<{ width: number; height: number; }>; + onRender: IEvent<{ start: number; end: number; }>; dispose(): void { throw new Error('Method not implemented.'); } diff --git a/src/Types.ts b/src/Types.ts index 1fa78824f..a6451cfdd 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -5,9 +5,9 @@ import { ITerminalOptions as IPublicTerminalOptions, IEventEmitter, IDisposable, IMarker } from 'xterm'; import { IColorSet, IRenderer } from './renderer/Types'; -import { IMouseZoneManager } from './ui/Types'; import { ICharset } from './core/Types'; import { ICircularList } from './common/Types'; +import { IEvent } from './common/EventEmitter2'; export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; @@ -22,12 +22,6 @@ export type CharacterJoinerHandler = (text: string) => [number, number][]; // BufferIndex denotes a position in the buffer: [rowIndex, colIndex] export type BufferIndex = [number, number]; -export const enum LinkHoverEventTypes { - HOVER = 'linkhover', - TOOLTIP = 'linktooltip', - LEAVE = 'linkleave' -} - /** * This interface encapsulates everything needed from the Terminal by the * InputHandler. This cleanly separates the large amount of methods needed by @@ -193,7 +187,7 @@ export interface ILinkMatcher { willLinkActivate?: (event: MouseEvent, uri: string) => boolean; } -export interface ILinkHoverEvent { +export interface ILinkifierEvent { x1: number; y1: number; x2: number; @@ -220,10 +214,6 @@ export interface ITerminal extends IPublicTerminal, IElementAccessor, IBufferAcc bracketedPasteMode: boolean; applicationCursor: boolean; - /** - * Emit the 'data' event and populate the given data. - * @param data The data to populate in the event. - */ handler(data: string): void; scrollLines(disp: number, suppressScrollEvent?: boolean): void; cancel(ev: Event, force?: boolean): boolean | void; @@ -290,6 +280,9 @@ export interface IMouseHelper { export interface ICharMeasure { width: number; height: number; + + onCharSizeChanged: IEvent; + measure(options: ITerminalOptions): void; } @@ -340,11 +333,13 @@ export interface IBuffer { getWhitespaceCell(attr?: IAttributeData): ICellData; } -export interface IBufferSet extends IEventEmitter { +export interface IBufferSet { alt: IBuffer; normal: IBuffer; active: IBuffer; + onBufferActivate: IEvent<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>; + activateNormalBuffer(): void; activateAltBuffer(fillAttr?: IAttributeData): void; } @@ -361,7 +356,17 @@ export interface ISelectionManager { selectWordAtCursor(event: MouseEvent): void; } -export interface ILinkifier extends IEventEmitter { +export interface ISelectionRedrawRequestEvent { + start: [number, number]; + end: [number, number]; + columnSelectMode: boolean; +} + +export interface ILinkifier { + onLinkHover: IEvent; + onLinkLeave: IEvent; + onLinkTooltip: IEvent; + attachToDom(mouseZoneManager: IMouseZoneManager): void; linkifyRows(start: number, end: number): void; registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number; @@ -637,3 +642,20 @@ export interface IBufferLine { isCombined(index: number): number; getString(index: number): string; } + +export interface IMouseZoneManager extends IDisposable { + add(zone: IMouseZone): void; + clearAll(start?: number, end?: number): void; +} + +export interface IMouseZone { + x1: number; + x2: number; + y1: number; + y2: number; + clickCallback: (e: MouseEvent) => any; + hoverCallback: (e: MouseEvent) => any | undefined; + tooltipCallback: (e: MouseEvent) => any | undefined; + leaveCallback: () => any | undefined; + willLinkActivate: (e: MouseEvent) => boolean; +} diff --git a/src/Viewport.ts b/src/Viewport.ts index 50d044571..6bf92531c 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -5,7 +5,7 @@ import { IColorSet } from './renderer/Types'; import { ITerminal, IViewport } from './Types'; -import { CharMeasure } from './ui/CharMeasure'; +import { CharMeasure } from './CharMeasure'; import { Disposable } from './common/Lifecycle'; import { addDisposableDomListener } from './ui/Lifecycle'; diff --git a/src/WindowsMode.ts b/src/WindowsMode.ts index 33a9bed5e..ac1d193e4 100644 --- a/src/WindowsMode.ts +++ b/src/WindowsMode.ts @@ -18,7 +18,7 @@ export function applyWindowsMode(terminal: ITerminal): IDisposable { // space. This is certainly not without its problems, but generally on // Windows when text reaches the end of the terminal it's likely going to be // wrapped. - return terminal.addDisposableListener('linefeed', () => { + return terminal.onLineFeed(() => { const line = terminal.buffer.lines.get(terminal.buffer.ybase + terminal.buffer.y - 1); const lastChar = line.get(terminal.cols - 1); diff --git a/src/addons/attach/Interfaces.ts b/src/addons/attach/Interfaces.ts index ab5846f5e..4b2690993 100644 --- a/src/addons/attach/Interfaces.ts +++ b/src/addons/attach/Interfaces.ts @@ -14,6 +14,7 @@ export interface IAttachAddonTerminal extends Terminal { __socket?: WebSocket; __attachSocketBuffer?: string; + __dataListener?: IDisposable; __getMessage?(ev: MessageEvent): void; __flushBuffer?(): void; diff --git a/src/addons/attach/attach.ts b/src/addons/attach/attach.ts index f121e2e2d..2c8a5d4d2 100644 --- a/src/addons/attach/attach.ts +++ b/src/addons/attach/attach.ts @@ -90,7 +90,8 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean addonTerminal._core.register(addSocketListener(socket, 'message', addonTerminal.__getMessage)); if (bidirectional) { - addonTerminal._core.register(addonTerminal.addDisposableListener('data', addonTerminal.__sendData)); + addonTerminal.__dataListener = addonTerminal.onData(addonTerminal.__sendData); + addonTerminal._core.register(addonTerminal.__dataListener); } addonTerminal._core.register(addSocketListener(socket, 'close', () => detach(addonTerminal, socket))); @@ -119,7 +120,8 @@ function addSocketListener(socket: WebSocket, type: string, handler: (this: WebS */ export function detach(term: Terminal, socket: WebSocket): void { const addonTerminal = term; - addonTerminal.off('data', addonTerminal.__sendData); + addonTerminal.__dataListener.dispose(); + addonTerminal.__dataListener = undefined; socket = (typeof socket === 'undefined') ? addonTerminal.__socket : socket; diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 96cd845d4..7db1ed433 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -4,6 +4,7 @@ */ import { ISearchHelper, ISearchAddonTerminal, ISearchOptions, ISearchResult } from './Interfaces'; +import { IDisposable } from 'xterm'; const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?'; const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs @@ -19,6 +20,8 @@ export class SearchHelper implements ISearchHelper { */ private _linesCache: string[] = null; private _linesCacheTimeoutId = 0; + private _cursorMoveListener: IDisposable | undefined; + private _resizeListener: IDisposable | undefined; constructor(private _terminal: ISearchAddonTerminal) { this._destroyLinesCache = this._destroyLinesCache.bind(this); @@ -55,12 +58,27 @@ export class SearchHelper implements ISearchHelper { this._initLinesCache(); + // A row that has isWrapped = false + let findingRow = startRow; + // index of beginning column that _findInLine need to scan. + let cumulativeCols = startCol; + // If startRow is wrapped row, scan for unwrapped row above. + // So we can start matching on wrapped line from long unwrapped line. + while (this._terminal._core.buffer.lines.get(findingRow).isWrapped) { + findingRow--; + cumulativeCols += this._terminal.cols; + } + // Search startRow - result = this._findInLine(term, startRow, startCol, searchOptions); + result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); // Search from startRow + 1 to end if (!result) { + for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) { + + // If the current line is wrapped line, increase index of column to ignore the previous scan + // Otherwise, reset beginning column index to zero with set new unwrapped line index result = this._findInLine(term, y, 0, searchOptions); if (result) { break; @@ -71,7 +89,7 @@ export class SearchHelper implements ISearchHelper { // Search from the top to the startRow (search the whole startRow again in // case startCol > 0) if (!result) { - for (let y = 0; y <= startRow; y++) { + for (let y = 0; y < findingRow; y++) { result = this._findInLine(term, y, 0, searchOptions); if (result) { break; @@ -100,8 +118,8 @@ export class SearchHelper implements ISearchHelper { } const isReverseSearch = true; - let startRow = this._terminal._core.buffer.ydisp; - let startCol: number = this._terminal._core.buffer.lines.get(startRow).length; + let startRow = this._terminal._core.buffer.ydisp + this._terminal.rows - 1; + let startCol = this._terminal.cols; if (selectionManager.selectionStart) { // Start from the selection start if there is a selection @@ -118,11 +136,24 @@ export class SearchHelper implements ISearchHelper { // Search from startRow - 1 to top if (!result) { + // If the line is wrapped line, increase number of columns that is needed to be scanned + // Se we can scan on wrapped line from unwrapped line + let cumulativeCols = this._terminal.cols; + if (this._terminal._core.buffer.lines.get(startRow).isWrapped) { + cumulativeCols += startCol; + } for (let y = startRow - 1; y >= 0; y--) { - result = this._findInLine(term, y, this._terminal._core.buffer.lines.get(y).length, searchOptions, isReverseSearch); + result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); if (result) { break; } + // If the current line is wrapped line, increase scanning range, + // preparing for scanning on unwrapped line + if (this._terminal._core.buffer.lines.get(y).isWrapped) { + cumulativeCols += this._terminal.cols; + } else { + cumulativeCols = this._terminal.cols; + } } } @@ -130,11 +161,17 @@ export class SearchHelper implements ISearchHelper { // case startCol > 0) if (!result) { const searchFrom = this._terminal._core.buffer.ybase + this._terminal.rows - 1; + let cumulativeCols = this._terminal.cols; for (let y = searchFrom; y >= startRow; y--) { - result = this._findInLine(term, y, this._terminal._core.buffer.lines.get(y).length, searchOptions, isReverseSearch); + result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); if (result) { break; } + if (this._terminal._core.buffer.lines.get(y).isWrapped) { + cumulativeCols += this._terminal.cols; + } else { + cumulativeCols = this._terminal.cols; + } } } @@ -148,7 +185,8 @@ export class SearchHelper implements ISearchHelper { private _initLinesCache(): void { if (!this._linesCache) { this._linesCache = new Array(this._terminal._core.buffer.length); - this._terminal.on('cursormove', this._destroyLinesCache); + this._cursorMoveListener = this._terminal.onCursorMove(() => this._destroyLinesCache()); + this._resizeListener = this._terminal.onResize(() => this._destroyLinesCache()); } window.clearTimeout(this._linesCacheTimeoutId); @@ -157,7 +195,14 @@ export class SearchHelper implements ISearchHelper { private _destroyLinesCache(): void { this._linesCache = null; - this._terminal.off('cursormove', this._destroyLinesCache); + if (this._cursorMoveListener) { + this._cursorMoveListener.dispose(); + this._cursorMoveListener = undefined; + } + if (this._resizeListener) { + this._resizeListener.dispose(); + this._resizeListener = undefined; + } if (this._linesCacheTimeoutId) { window.clearTimeout(this._linesCacheTimeoutId); this._linesCacheTimeoutId = 0; @@ -187,10 +232,11 @@ export class SearchHelper implements ISearchHelper { * @return The search result if it was found. */ protected _findInLine(term: string, row: number, col: number, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult { + + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). if (this._terminal._core.buffer.lines.get(row).isWrapped) { return; } - let stringLine = this._linesCache ? this._linesCache[row] : void 0; if (stringLine === void 0) { stringLine = this.translateBufferLineToStringWithWrap(row, true); diff --git a/src/addons/terminado/Interfaces.ts b/src/addons/terminado/Interfaces.ts index 8f17b0cc7..dd7b045ca 100644 --- a/src/addons/terminado/Interfaces.ts +++ b/src/addons/terminado/Interfaces.ts @@ -5,11 +5,16 @@ * Implements the attach method, that attaches the terminal to a WebSocket stream. */ -import { Terminal } from 'xterm'; +import { Terminal, IDisposable } from 'xterm'; export interface ITerminadoAddonTerminal extends Terminal { + _core: { + register(d: T): void; + }; + __socket?: WebSocket; __attachSocketBuffer?: string; + __dataListener?: IDisposable; __getMessage?(ev: MessageEvent): void; __flushBuffer?(): void; diff --git a/src/addons/terminado/terminado.ts b/src/addons/terminado/terminado.ts index 136eea8e8..9895a07b8 100644 --- a/src/addons/terminado/terminado.ts +++ b/src/addons/terminado/terminado.ts @@ -59,9 +59,9 @@ export function terminadoAttach(term: Terminal, socket: WebSocket, bidirectional socket.addEventListener('message', addonTerminal.__getMessage); if (bidirectional) { - addonTerminal.on('data', addonTerminal.__sendData); + addonTerminal._core.register(addonTerminal.onData(addonTerminal.__sendData)); } - addonTerminal.on('resize', addonTerminal.__setSize); + addonTerminal._core.register(addonTerminal.onResize(addonTerminal.__setSize)); socket.addEventListener('close', () => terminadoDetach(addonTerminal, socket)); socket.addEventListener('error', () => terminadoDetach(addonTerminal, socket)); @@ -75,7 +75,8 @@ export function terminadoAttach(term: Terminal, socket: WebSocket, bidirectional */ export function terminadoDetach(term: Terminal, socket: WebSocket): void { const addonTerminal = term; - addonTerminal.off('data', addonTerminal.__sendData); + addonTerminal.__dataListener.dispose(); + addonTerminal.__dataListener = undefined; socket = (typeof socket === 'undefined') ? addonTerminal.__socket : socket; diff --git a/src/addons/webLinks/webLinks.test.ts b/src/addons/webLinks/webLinks.test.ts index 1e8a4ae7e..da5569ab8 100644 --- a/src/addons/webLinks/webLinks.test.ts +++ b/src/addons/webLinks/webLinks.test.ts @@ -28,63 +28,185 @@ describe('webLinks addon', () => { }); }); - it('should allow ~ character in URI path', () => { - const term = new MockTerminal(); - webLinks.webLinksInit(term); + describe('should allow simple URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); - const row = ' http://foo.com/a~b#c~d?e~f '; + const row = ' http://foo.com '; - const match = row.match(term.regex); - const uri = match[term.options.matchIndex]; + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; - assert.equal(uri, 'http://foo.com/a~b#c~d?e~f'); + assert.equal(uri, 'http://foo.com'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io'); + }); }); - it('should allow : character in URI path', () => { - const term = new MockTerminal(); - webLinks.webLinksInit(term); + describe('should allow ~ character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); - const row = ' http://foo.com/colon:test '; + const row = ' http://foo.com/a~b#c~d?e~f '; - const match = row.match(term.regex); - const uri = match[term.options.matchIndex]; + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; - assert.equal(uri, 'http://foo.com/colon:test'); + assert.equal(uri, 'http://foo.com/a~b#c~d?e~f'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/a~b#c~d?e~f '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/a~b#c~d?e~f'); + }); }); - it('should not allow : character at the end of a URI path', () => { - const term = new MockTerminal(); - webLinks.webLinksInit(term); + describe('should allow : character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); - const row = ' http://foo.com/colon:test: '; + const row = ' http://foo.com/colon:test '; - const match = row.match(term.regex); - const uri = match[term.options.matchIndex]; + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; - assert.equal(uri, 'http://foo.com/colon:test'); + assert.equal(uri, 'http://foo.com/colon:test'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/colon:test '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/colon:test'); + }); }); - it('should not allow " character at the end of a URI enclosed with ""', () => { - const term = new MockTerminal(); - webLinks.webLinksInit(term); + describe('should not allow : character at the end of a URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); - const row = '"http://foo.com/"'; + const row = ' http://foo.com/colon:test: '; - const match = row.match(term.regex); - const uri = match[term.options.matchIndex]; + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; - assert.equal(uri, 'http://foo.com/'); + assert.equal(uri, 'http://foo.com/colon:test'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://bar.io/colon:test: '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/colon:test'); + }); }); - it('should not allow \' character at the end of a URI enclosed with \'\'', () => { - const term = new MockTerminal(); - webLinks.webLinksInit(term); + describe('should not allow " character at the end of a URI enclosed with ""', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); - const row = '\'http://foo.com/\''; + const row = '"http://foo.com/"'; - const match = row.match(term.regex); - const uri = match[term.options.matchIndex]; + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; - assert.equal(uri, 'http://foo.com/'); + assert.equal(uri, 'http://foo.com/'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '"http://bar.io/"'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/'); + }); + }); + + describe('should not allow \' character at the end of a URI enclosed with \'\'', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '\'http://foo.com/\''; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = '\'http://bar.io/\''; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/'); + }); + }); + + describe('should allow + character in URI path', () => { + it('foo.com', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = 'http://foo.com/subpath/+/id'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/subpath/+/id'); + }); + + it('bar.io', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = 'http://bar.io/subpath/+/id'; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://bar.io/subpath/+/id'); + }); }); }); diff --git a/src/addons/webLinks/webLinks.ts b/src/addons/webLinks/webLinks.ts index f0d69cc58..8a0fec097 100644 --- a/src/addons/webLinks/webLinks.ts +++ b/src/addons/webLinks/webLinks.ts @@ -14,7 +14,8 @@ const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})'; const localHostClause = '(localhost)'; const portClause = '(:\\d{1,5})'; const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?'; -const pathClause = '(\\/[\\/\\w\\.\\-%~:]*)*([^:"\'\\s])'; +const pathCharacterSet = '(\\/[\\/\\w\\.\\-%~:+]*)*([^:"\'\\s])'; +const pathClause = '(' + pathCharacterSet + ')?'; const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*'; const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?'; const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?'; diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 90891b72a..d4fc41cda 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -3,8 +3,8 @@ * @license MIT */ -import { EventEmitter } from './EventEmitter'; import { ICircularList } from './Types'; +import { EventEmitter2, IEvent } from './EventEmitter2'; export interface IInsertEvent { index: number; @@ -20,15 +20,21 @@ export interface IDeleteEvent { * Represents a circular list; a list with a maximum size that wraps around when push is called, * overriding values at the start of the list. */ -export class CircularList extends EventEmitter implements ICircularList { +export class CircularList implements ICircularList { protected _array: (T | undefined)[]; private _startIndex: number; private _length: number; + public onDeleteEmitter = new EventEmitter2(); + public get onDelete(): IEvent { return this.onDeleteEmitter.event; } + public onInsertEmitter = new EventEmitter2(); + public get onInsert(): IEvent { return this.onInsertEmitter.event; } + public onTrimEmitter = new EventEmitter2(); + public get onTrim(): IEvent { return this.onTrimEmitter.event; } + constructor( private _maxLength: number ) { - super(); this._array = new Array(this._maxLength); this._startIndex = 0; this._length = 0; @@ -101,7 +107,7 @@ export class CircularList extends EventEmitter implements ICircularList { this._array[this._getCyclicIndex(this._length)] = value; if (this._length === this._maxLength) { this._startIndex = ++this._startIndex % this._maxLength; - this.emitMayRemoveListeners('trim', 1); + this.onTrimEmitter.fire(1); } else { this._length++; } @@ -117,7 +123,7 @@ export class CircularList extends EventEmitter implements ICircularList { throw new Error('Can only recycle when the buffer is full'); } this._startIndex = ++this._startIndex % this._maxLength; - this.emitMayRemoveListeners('trim', 1); + this.onTrimEmitter.fire(1); return this._array[this._getCyclicIndex(this._length - 1)]!; } @@ -167,7 +173,7 @@ export class CircularList extends EventEmitter implements ICircularList { const countToTrim = (this._length + items.length) - this._maxLength; this._startIndex += countToTrim; this._length = this._maxLength; - this.emitMayRemoveListeners('trim', countToTrim); + this.onTrimEmitter.fire(countToTrim); } else { this._length += items.length; } @@ -183,7 +189,7 @@ export class CircularList extends EventEmitter implements ICircularList { } this._startIndex += count; this._length -= count; - this.emitMayRemoveListeners('trim', count); + this.onTrimEmitter.fire(count); } public shiftElements(start: number, count: number, offset: number): void { @@ -207,7 +213,7 @@ export class CircularList extends EventEmitter implements ICircularList { while (this._length > this._maxLength) { this._length--; this._startIndex++; - this.emitMayRemoveListeners('trim', 1); + this.onTrimEmitter.fire(1); } } } else { diff --git a/src/common/EventEmitter2.test.ts b/src/common/EventEmitter2.test.ts new file mode 100644 index 000000000..53ee4c4fe --- /dev/null +++ b/src/common/EventEmitter2.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { EventEmitter2 } from './EventEmitter2'; + +describe('EventEmitter2', () => { + it('should fire listeners multiple times', () => { + const order: string[] = []; + const emitter = new EventEmitter2(); + emitter.event(data => order.push(data + 'a')); + emitter.event(data => order.push(data + 'b')); + emitter.fire(1); + emitter.fire(2); + assert.deepEqual(order, [ '1a', '1b', '2a', '2b' ]); + }); + + it('should not fire listeners once disposed', () => { + const order: string[] = []; + const emitter = new EventEmitter2(); + emitter.event(data => order.push(data + 'a')); + const disposeB = emitter.event(data => order.push(data + 'b')); + emitter.event(data => order.push(data + 'c')); + emitter.fire(1); + disposeB.dispose(); + emitter.fire(2); + assert.deepEqual(order, [ '1a', '1b', '1c', '2a', '2c' ]); + }); +}); diff --git a/src/common/EventEmitter2.ts b/src/common/EventEmitter2.ts new file mode 100644 index 000000000..71c0b72dd --- /dev/null +++ b/src/common/EventEmitter2.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from './Types'; + +interface IListener { + (e: T): void; +} + +export interface IEvent { + (listener: (e: T) => any): IDisposable; +} + +export class EventEmitter2 { + private _listeners: IListener[] = []; + private _event?: IEvent; + + public get event(): IEvent { + if (!this._event) { + this._event = (listener: (e: T) => any) => { + this._listeners.push(listener); + const disposable = { + dispose: () => { + for (let i = 0; i < this._listeners.length; i++) { + if (this._listeners[i] === listener) { + this._listeners.splice(i, 1); + return; + } + } + } + }; + return disposable; + }; + } + return this._event; + } + + public fire(data: T): void { + const queue: IListener[] = []; + for (let i = 0; i < this._listeners.length; i++) { + queue.push(this._listeners[i]); + } + for (let i = 0; i < queue.length; i++) { + queue[i].call(undefined, data); + } + } +} diff --git a/src/common/Types.ts b/src/common/Types.ts index 8ad98d99c..b2111bfc7 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -3,6 +3,9 @@ * @license MIT */ +import { IEvent, EventEmitter2 } from './EventEmitter2'; +import { IDeleteEvent, IInsertEvent } from './CircularList'; + export interface IDisposable { dispose(): void; } @@ -30,11 +33,18 @@ export interface IKeyboardEvent { type: string; } -export interface ICircularList extends IEventEmitter { +export interface ICircularList { length: number; maxLength: number; isFull: boolean; + onDeleteEmitter: EventEmitter2; + onDelete: IEvent; + onInsertEmitter: EventEmitter2; + onInsert: IEvent; + onTrimEmitter: EventEmitter2; + onTrim: IEvent; + get(index: number): T | undefined; set(index: number, value: T): void; push(value: T): void; diff --git a/src/core/input/Keyboard.ts b/src/core/input/Keyboard.ts index 5e4c4e617..36097f912 100644 --- a/src/core/input/Keyboard.ts +++ b/src/core/input/Keyboard.ts @@ -352,6 +352,10 @@ export function evaluateKeyboardEvent( } else if (ev.key && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.keyCode >= 48 && ev.key.length === 1) { // Include only keys that that result in a _single_ character; don't include num lock, volume up, etc. result.key = ev.key; + } else if (ev.key && ev.ctrlKey) { + if (ev.key === '_') { // ^_ + result.key = C0.US; + } } break; } diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 35423b80a..eb3809b17 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -7,6 +7,7 @@ import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILink import { ITerminal, IBufferLine, IBuffer } from '../Types'; import { Terminal as TerminalCore } from '../Terminal'; import * as Strings from '../Strings'; +import { IEvent } from '../common/EventEmitter2'; export class Terminal implements ITerminalApi { private _core: ITerminal; @@ -15,12 +16,22 @@ export class Terminal implements ITerminalApi { this._core = new TerminalCore(options); } + public get onCursorMove(): IEvent { return this._core.onCursorMove; } + public get onLineFeed(): IEvent { return this._core.onLineFeed; } + public get onSelectionChange(): IEvent { return this._core.onSelectionChange; } + public get onData(): IEvent { return this._core.onData; } + public get onTitleChange(): IEvent { return this._core.onTitleChange; } + public get onScroll(): IEvent { return this._core.onScroll; } + public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._core.onKey; } + public get onRender(): IEvent<{ start: number, end: number }> { return this._core.onRender; } + public get onResize(): IEvent<{ cols: number, rows: number }> { return this._core.onResize; } + 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 buffer(): IBufferApi { return new BufferApiView(this._core.buffer); } - public get markers(): IMarker[] { return this._core.markers; } + public get markers(): ReadonlyArray { return this._core.markers; } public blur(): void { this._core.blur(); } diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index 008b216a9..ace1f6781 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -10,6 +10,7 @@ import BaseCharAtlas from './atlas/BaseCharAtlas'; import { acquireCharAtlas } from './atlas/CharAtlasCache'; import { CellData, AttributeData } from '../BufferLine'; import { WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from '../Buffer'; +import { JoinedCellData } from './CharacterJoinerRegistry'; export abstract class BaseRenderLayer implements IRenderLayer { private _canvas: HTMLCanvasElement; @@ -261,7 +262,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { protected drawChars(terminal: ITerminal, cell: ICellData, x: number, y: number): void { // skip cache right away if we draw in RGB - if (cell.isFgRGB() || cell.isBgRGB()) { + // Note: to avoid bad runtime JoinedCellData will be skipped + // in the cache handler (atlasDidDraw == false) itself and + // fall through to uncached later down below + if (cell.isFgRGB() || cell.isBgRGB() || cell instanceof JoinedCellData) { this._drawUncachedChars(terminal, cell, x, y); return; } @@ -322,14 +326,18 @@ export abstract class BaseRenderLayer implements IRenderLayer { } else { this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css; } - } else if (cell.isFgRGB()) { - this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; - } else if (cell.isFgPalette()) { - let fg = cell.getFgColor(); - if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) { - fg += 8; + } else { + if (cell.isFgDefault()) { + this._ctx.fillStyle = this._colors.foreground.css; + } else if (cell.isFgRGB()) { + this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; + } else { + let fg = cell.getFgColor(); + if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) { + fg += 8; + } + this._ctx.fillStyle = this._colors.ansi[fg].css; } - this._ctx.fillStyle = this._colors.ansi[fg].css; } this._clipRow(terminal, y); diff --git a/src/renderer/CharacterJoinerRegistry.test.ts b/src/renderer/CharacterJoinerRegistry.test.ts index effdbfaaf..2ccfd1978 100644 --- a/src/renderer/CharacterJoinerRegistry.test.ts +++ b/src/renderer/CharacterJoinerRegistry.test.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; -import { MockTerminal, MockBuffer } from '../ui/TestUtils.test'; +import { MockTerminal, MockBuffer } from '../TestUtils.test'; import { CircularList } from '../common/CircularList'; import { ICharacterJoinerRegistry } from './Types'; diff --git a/src/renderer/CharacterJoinerRegistry.ts b/src/renderer/CharacterJoinerRegistry.ts index 8c521dd9d..7a3bb8e05 100644 --- a/src/renderer/CharacterJoinerRegistry.ts +++ b/src/renderer/CharacterJoinerRegistry.ts @@ -1,8 +1,53 @@ -import { ITerminal, IBufferLine } from '../Types'; +import { ITerminal, IBufferLine, ICellData, CharData } from '../Types'; import { ICharacterJoinerRegistry, ICharacterJoiner } from './Types'; -import { CellData } from '../BufferLine'; +import { CellData, Content, AttributeData } from '../BufferLine'; import { WHITESPACE_CELL_CHAR } from '../Buffer'; +export class JoinedCellData extends AttributeData implements ICellData { + private _width: number; + // .content carries no meaning for joined CellData, simply nullify it + // thus we have to overload all other .content accessors + public content: number = 0; + public fg: number; + public bg: number; + public combinedData: string = ''; + + constructor(firstCell: ICellData, chars: string, width: number) { + super(); + this.fg = firstCell.fg; + this.bg = firstCell.bg; + this.combinedData = chars; + this._width = width; + } + + public isCombined(): number { + // always mark joined cell data as combined + return Content.IS_COMBINED_MASK; + } + + public getWidth(): number { + return this._width; + } + + public getChars(): string { + return this.combinedData; + } + + public getCode(): number { + // code always gets the highest possible fake codepoint (read as -1) + // this is needed as code is used by caches as identifier + return 0x1FFFFF; + } + + public setFromCharData(value: CharData): void { + throw new Error('not implemented'); + } + + public getAsCharData(): CharData { + return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; + } +} + export class CharacterJoinerRegistry implements ICharacterJoinerRegistry { private _characterJoiners: ICharacterJoiner[] = []; diff --git a/src/renderer/LinkRenderLayer.ts b/src/renderer/LinkRenderLayer.ts index 855830e40..abb899b0f 100644 --- a/src/renderer/LinkRenderLayer.ts +++ b/src/renderer/LinkRenderLayer.ts @@ -3,19 +3,19 @@ * @license MIT */ -import { ILinkHoverEvent, ITerminal, ILinkifierAccessor, LinkHoverEventTypes } from '../Types'; +import { ILinkifierEvent, ITerminal, ILinkifierAccessor } from '../Types'; import { IColorSet, IRenderDimensions } from './Types'; import { BaseRenderLayer } from './BaseRenderLayer'; import { INVERTED_DEFAULT_COLOR } from './atlas/Types'; import { is256Color } from './atlas/CharAtlasUtils'; export class LinkRenderLayer extends BaseRenderLayer { - private _state: ILinkHoverEvent = null; + private _state: ILinkifierEvent = null; constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { super(container, 'link', zIndex, true, colors); - terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: ILinkHoverEvent) => this._onLinkHover(e)); - terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: ILinkHoverEvent) => this._onLinkLeave(e)); + terminal.linkifier.onLinkHover(e => this._onLinkHover(e)); + terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e)); } public resize(terminal: ITerminal, dim: IRenderDimensions): void { @@ -40,7 +40,7 @@ export class LinkRenderLayer extends BaseRenderLayer { } } - private _onLinkHover(e: ILinkHoverEvent): void { + private _onLinkHover(e: ILinkifierEvent): void { if (e.fg === INVERTED_DEFAULT_COLOR) { this._ctx.fillStyle = this._colors.background.css; } else if (is256Color(e.fg)) { @@ -64,7 +64,7 @@ export class LinkRenderLayer extends BaseRenderLayer { this._state = e; } - private _onLinkLeave(e: ILinkHoverEvent): void { + private _onLinkLeave(e: ILinkifierEvent): void { this._clearCurrentLink(); } } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 2c1b516ae..14ce72b35 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -10,13 +10,14 @@ import { ColorManager } from './ColorManager'; import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions, ICharacterJoinerRegistry } from './Types'; import { ITerminal, CharacterJoinerHandler } from '../Types'; import { LinkRenderLayer } from './LinkRenderLayer'; -import { EventEmitter } from '../common/EventEmitter'; import { RenderDebouncer } from '../ui/RenderDebouncer'; import { ScreenDprMonitor } from '../ui/ScreenDprMonitor'; import { ITheme } from 'xterm'; import { CharacterJoinerRegistry } from '../renderer/CharacterJoinerRegistry'; +import { EventEmitter2, IEvent } from '../common/EventEmitter2'; +import { Disposable } from '../common/Lifecycle'; -export class Renderer extends EventEmitter implements IRenderer { +export class Renderer extends Disposable implements IRenderer { private _renderDebouncer: RenderDebouncer; private _renderLayers: IRenderLayer[]; @@ -29,6 +30,11 @@ export class Renderer extends EventEmitter implements IRenderer { public colorManager: ColorManager; public dimensions: IRenderDimensions; + private _onCanvasResize = new EventEmitter2<{ width: number, height: number }>(); + public get onCanvasResize(): IEvent<{ width: number, height: number }> { return this._onCanvasResize.event; } + private _onRender = new EventEmitter2<{ start: number, end: number }>(); + public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } + constructor(private _terminal: ITerminal, theme: ITheme) { super(); const allowTransparency = this._terminal.options.allowTransparency; @@ -62,7 +68,7 @@ export class Renderer extends EventEmitter implements IRenderer { this._updateDimensions(); this.onOptionsChanged(); - this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._renderDebouncer = new RenderDebouncer(this._renderRows.bind(this)); this._screenDprMonitor = new ScreenDprMonitor(); this._screenDprMonitor.setListener(() => this.onWindowResize(window.devicePixelRatio)); this.register(this._screenDprMonitor); @@ -134,7 +140,7 @@ export class Renderer extends EventEmitter implements IRenderer { this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth}px`; this._terminal.screenElement.style.height = `${this.dimensions.canvasHeight}px`; - this.emit('resize', { + this._onCanvasResize.fire({ width: this.dimensions.canvasWidth, height: this.dimensions.canvasHeight }); @@ -188,7 +194,7 @@ export class Renderer extends EventEmitter implements IRenderer { this._needsFullRefresh = true; return; } - this._renderDebouncer.refresh(start, end); + this._renderDebouncer.refresh(start, end, this._terminal.rows); } /** @@ -197,7 +203,7 @@ export class Renderer extends EventEmitter implements IRenderer { */ private _renderRows(start: number, end: number): void { this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); - this._terminal.emit('refresh', { start, end }); + this._onRender.fire({ start, end }); } /** diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index 2547ecb24..d54008b8a 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -9,6 +9,7 @@ import { CharData, ITerminal, ICellData } from '../Types'; import { GridCache } from './GridCache'; import { BaseRenderLayer } from './BaseRenderLayer'; import { CellData, AttributeData, Content } from '../BufferLine'; +import { JoinedCellData } from './CharacterJoinerRegistry'; /** * This CharData looks like a null character, which will forc a clear and render @@ -89,15 +90,12 @@ export class TextRenderLayer extends BaseRenderLayer { // We already know the exact start and end column of the joined range, // so we get the string and width representing it directly - cell = CellData.fromCharData([ - 0, + + cell = new JoinedCellData( + this._workCell, line.translateToString(true, range[0], range[1]), - range[1] - range[0], - 0xFFFFFF - ]); - // hacky: patch attrs - cell.fg = this._workCell.fg; - cell.bg = this._workCell.bg; + range[1] - range[0] + ); // Skip over the cells occupied by this range in the loop lastCharX = range[1] - 1; @@ -217,10 +215,18 @@ export class TextRenderLayer extends BaseRenderLayer { } else { this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css; } - } else if (cell.isFgRGB()) { - this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; - } else if (cell.isFgPalette()) { - this._ctx.fillStyle = this._colors.ansi[cell.getFgColor()].css; + } else { + if (cell.isFgDefault()) { + this._ctx.fillStyle = this._colors.foreground.css; + } else if (cell.isFgRGB()) { + this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; + } else { + let fg = cell.getFgColor(); + if (terminal.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8) { + fg += 8; + } + this._ctx.fillStyle = this._colors.ansi[fg].css; + } } this.fillBottomLineAtCells(x, y, cell.getWidth()); diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index f2271f95c..9d2ddcc32 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -4,7 +4,8 @@ */ import { ITerminal, CharacterJoinerHandler } from '../Types'; -import { IEventEmitter, ITheme, IDisposable } from 'xterm'; +import { ITheme, IDisposable } from 'xterm'; +import { IEvent } from '../common/EventEmitter2'; /** * Flags used to render terminal text properly. @@ -23,10 +24,13 @@ export const enum FLAGS { * Note that IRenderer implementations should emit the refresh event after * rendering rows to the screen. */ -export interface IRenderer extends IEventEmitter, IDisposable { +export interface IRenderer extends IDisposable { dimensions: IRenderDimensions; colorManager: IColorManager; + onCanvasResize: IEvent<{ width: number, height: number }>; + onRender: IEvent<{ start: number, end: number }>; + dispose(): void; setTheme(theme: ITheme): IColorSet; onWindowResize(devicePixelRatio: number): void; diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts index c4da6710c..79389618e 100644 --- a/src/renderer/dom/DomRenderer.ts +++ b/src/renderer/dom/DomRenderer.ts @@ -4,13 +4,14 @@ */ import { IRenderer, IRenderDimensions, IColorSet } from '../Types'; -import { ILinkHoverEvent, ITerminal, CharacterJoinerHandler, LinkHoverEventTypes } from '../../Types'; +import { ILinkifierEvent, ITerminal, CharacterJoinerHandler } from '../../Types'; import { ITheme } from 'xterm'; -import { EventEmitter } from '../../common/EventEmitter'; import { ColorManager } from '../ColorManager'; import { RenderDebouncer } from '../../ui/RenderDebouncer'; import { BOLD_CLASS, ITALIC_CLASS, CURSOR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_BLINK_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DomRendererRowFactory } from './DomRendererRowFactory'; import { INVERTED_DEFAULT_COLOR } from '../atlas/Types'; +import { EventEmitter2, IEvent } from '../../common/EventEmitter2'; +import { Disposable } from '../../common/Lifecycle'; const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; const ROW_CONTAINER_CLASS = 'xterm-rows'; @@ -29,7 +30,7 @@ let nextTerminalId = 1; * particularly fast or feature complete, more just stable and usable for when * canvas is not an option. */ -export class DomRenderer extends EventEmitter implements IRenderer { +export class DomRenderer extends Disposable implements IRenderer { private _renderDebouncer: RenderDebouncer; private _rowFactory: DomRendererRowFactory; private _terminalClass: number = nextTerminalId++; @@ -43,6 +44,11 @@ export class DomRenderer extends EventEmitter implements IRenderer { public dimensions: IRenderDimensions; public colorManager: ColorManager; + private _onCanvasResize = new EventEmitter2<{ width: number, height: number }>(); + public get onCanvasResize(): IEvent<{ width: number, height: number }> { return this._onCanvasResize.event; } + private _onRender = new EventEmitter2<{ start: number, end: number }>(); + public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } + constructor(private _terminal: ITerminal, theme: ITheme | undefined) { super(); const allowTransparency = this._terminal.options.allowTransparency; @@ -74,15 +80,15 @@ export class DomRenderer extends EventEmitter implements IRenderer { }; this._updateDimensions(); - this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._renderDebouncer = new RenderDebouncer(this._renderRows.bind(this)); this._rowFactory = new DomRendererRowFactory(_terminal.options, document); this._terminal.element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); this._terminal.screenElement.appendChild(this._rowContainer); this._terminal.screenElement.appendChild(this._selectionContainer); - this._terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: ILinkHoverEvent) => this._onLinkHover(e)); - this._terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: ILinkHoverEvent) => this._onLinkLeave(e)); + this._terminal.linkifier.onLinkHover(e => this._onLinkHover(e)); + this._terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e)); } public dispose(): void { @@ -168,9 +174,9 @@ export class DomRenderer extends EventEmitter implements IRenderer { // Blink animation styles += `@keyframes blink {` + - ` 0 % { opacity: 1.0; }` + + ` 0% { opacity: 1.0; }` + ` 50% { opacity: 0.0; }` + - ` 100 % { opacity: 1.0; }` + + ` 100% { opacity: 1.0; }` + `}`; // Cursor styles += @@ -238,6 +244,10 @@ export class DomRenderer extends EventEmitter implements IRenderer { public onResize(cols: number, rows: number): void { this._refreshRowElements(cols, rows); this._updateDimensions(); + this._onCanvasResize.fire({ + width: this.dimensions.canvasWidth, + height: this.dimensions.canvasHeight + }); } public onCharSizeChanged(): void { @@ -330,7 +340,7 @@ export class DomRenderer extends EventEmitter implements IRenderer { } public refreshRows(start: number, end: number): void { - this._renderDebouncer.refresh(start, end); + this._renderDebouncer.refresh(start, end, this._terminal.rows); } private _renderRows(start: number, end: number): void { @@ -350,7 +360,7 @@ export class DomRenderer extends EventEmitter implements IRenderer { rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.actualCellWidth, terminal.cols)); } - this._terminal.emit('refresh', {start, end}); + this._onRender.fire({ start, end }); } private get _terminalSelector(): string { @@ -360,11 +370,11 @@ export class DomRenderer extends EventEmitter implements IRenderer { public registerCharacterJoiner(handler: CharacterJoinerHandler): number { return -1; } public deregisterCharacterJoiner(joinerId: number): boolean { return false; } - private _onLinkHover(e: ILinkHoverEvent): void { + private _onLinkHover(e: ILinkifierEvent): void { this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, true); } - private _onLinkLeave(e: ILinkHoverEvent): void { + private _onLinkLeave(e: ILinkifierEvent): void { this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, false); } diff --git a/src/renderer/dom/DomRendererRowFactory.test.ts b/src/renderer/dom/DomRendererRowFactory.test.ts index 4402943c7..076f5d6a3 100644 --- a/src/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/renderer/dom/DomRendererRowFactory.test.ts @@ -100,6 +100,16 @@ describe('DomRendererRowFactory', () => { ); }); + it('should add class for underline', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + lineData.setCell(0, cell); + const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add classes for 256 foreground colors', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg |= Attributes.CM_P256; diff --git a/src/renderer/dom/DomRendererRowFactory.ts b/src/renderer/dom/DomRendererRowFactory.ts index a6cbeb786..60e2d5098 100644 --- a/src/renderer/dom/DomRendererRowFactory.ts +++ b/src/renderer/dom/DomRendererRowFactory.ts @@ -11,6 +11,7 @@ import { CellData, AttributeData } from '../../BufferLine'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; export const ITALIC_CLASS = 'xterm-italic'; +export const UNDERLINE_CLASS = 'xterm-underline'; export const CURSOR_CLASS = 'xterm-cursor'; export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink'; export const CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block'; @@ -88,6 +89,10 @@ export class DomRendererRowFactory { charElement.classList.add(DIM_CLASS); } + if (this._workCell.isUnderline()) { + charElement.classList.add(UNDERLINE_CLASS); + } + charElement.textContent = this._workCell.getChars() || WHITESPACE_CELL_CHAR; const swapColor = this._workCell.isInverse(); diff --git a/src/tsconfig.json b/src/tsconfig.json index f0b1c7490..1e8230a2d 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -24,6 +24,7 @@ ], "references": [ { "path": "./common" }, - { "path": "./core" } + { "path": "./core" }, + { "path": "./ui" } ] } diff --git a/src/ui/Lifecycle.ts b/src/ui/Lifecycle.ts index 9f058106a..4d36a1f12 100644 --- a/src/ui/Lifecycle.ts +++ b/src/ui/Lifecycle.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IDisposable } from 'xterm'; +import { IDisposable } from '../common/Types'; /** * Adds a disposable listener to a node in the DOM, returning the disposable. @@ -24,8 +24,6 @@ export function addDisposableDomListener( return; } node.removeEventListener(type, handler, useCapture); - node = null; - handler = null; } }; } diff --git a/src/ui/RenderDebouncer.ts b/src/ui/RenderDebouncer.ts index 775b7f748..ee2ab16e7 100644 --- a/src/ui/RenderDebouncer.ts +++ b/src/ui/RenderDebouncer.ts @@ -1,37 +1,39 @@ -import { ITerminal } from '../Types'; -import { IDisposable } from 'xterm'; +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from '../common/Types'; /** * Debounces calls to render terminal rows using animation frames. */ export class RenderDebouncer implements IDisposable { - private _rowStart: number; - private _rowEnd: number; - private _animationFrame: number = null; + private _rowStart: number | undefined; + private _rowEnd: number | undefined; + private _rowCount: number | undefined; + private _animationFrame: number | undefined; constructor( - private _terminal: ITerminal, - private _callback: (start: number, end: number) => void + private _renderCallback: (start: number, end: number) => void ) { } public dispose(): void { if (this._animationFrame) { window.cancelAnimationFrame(this._animationFrame); - this._animationFrame = null; + this._animationFrame = undefined; } } - public refresh(rowStart: number, rowEnd: number): void { + public refresh(rowStart: number, rowEnd: number, rowCount: number): void { + this._rowCount = rowCount; // Get the min/max row start/end for the arg values - rowStart = rowStart !== null && rowStart !== undefined ? rowStart : 0; - rowEnd = rowEnd !== null && rowEnd !== undefined ? rowEnd : this._terminal.rows - 1; - // Check whether the row start/end values have already been set - const isRowStartSet = this._rowStart !== undefined && this._rowStart !== null; - const isRowEndSet = this._rowEnd !== undefined && this._rowEnd !== null; + rowStart = rowStart !== undefined ? rowStart : 0; + rowEnd = rowEnd !== undefined ? rowEnd : this._rowCount - 1; // Set the properties to the updated values - this._rowStart = isRowStartSet ? Math.min(this._rowStart, rowStart) : rowStart; - this._rowEnd = isRowEndSet ? Math.max(this._rowEnd, rowEnd) : rowEnd; + this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart; + this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd; if (this._animationFrame) { return; @@ -41,16 +43,21 @@ export class RenderDebouncer implements IDisposable { } private _innerRefresh(): void { + // Make sure values are set + if (this._rowStart === undefined || this._rowEnd === undefined || this._rowCount === undefined) { + return; + } + // Clamp values this._rowStart = Math.max(this._rowStart, 0); - this._rowEnd = Math.min(this._rowEnd, this._terminal.rows - 1); + this._rowEnd = Math.min(this._rowEnd, this._rowCount - 1); // Run render callback - this._callback(this._rowStart, this._rowEnd); + this._renderCallback(this._rowStart, this._rowEnd); // Reset debouncer - this._rowStart = null; - this._rowEnd = null; - this._animationFrame = null; + this._rowStart = undefined; + this._rowEnd = undefined; + this._animationFrame = undefined; } } diff --git a/src/ui/ScreenDprMonitor.ts b/src/ui/ScreenDprMonitor.ts index d66eeb64f..37c6a0dab 100644 --- a/src/ui/ScreenDprMonitor.ts +++ b/src/ui/ScreenDprMonitor.ts @@ -18,10 +18,10 @@ export type ScreenDprListener = (newDevicePixelRatio?: number, oldDevicePixelRat * monitor with a different DPI. */ export class ScreenDprMonitor extends Disposable { - private _currentDevicePixelRatio: number; - private _outerListener: (this: MediaQueryList, ev: MediaQueryListEvent) => any; - private _listener: ScreenDprListener; - private _resolutionMediaMatchList: MediaQueryList; + private _currentDevicePixelRatio: number = window.devicePixelRatio; + private _outerListener: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | undefined; + private _listener: ScreenDprListener | undefined; + private _resolutionMediaMatchList: MediaQueryList | undefined; public setListener(listener: ScreenDprListener): void { if (this._listener) { @@ -29,6 +29,9 @@ export class ScreenDprMonitor extends Disposable { } this._listener = listener; this._outerListener = () => { + if (!this._listener) { + return; + } this._listener(window.devicePixelRatio, this._currentDevicePixelRatio); this._updateDpr(); }; @@ -41,10 +44,13 @@ export class ScreenDprMonitor extends Disposable { } private _updateDpr(): void { - // Clear listeners for old DPR - if (this._resolutionMediaMatchList) { - this._resolutionMediaMatchList.removeListener(this._outerListener); + if (!this._resolutionMediaMatchList || !this._outerListener) { + return; } + + // Clear listeners for old DPR + this._resolutionMediaMatchList.removeListener(this._outerListener); + // Add listeners for new DPR this._currentDevicePixelRatio = window.devicePixelRatio; this._resolutionMediaMatchList = window.matchMedia(`screen and (resolution: ${window.devicePixelRatio}dppx)`); @@ -52,11 +58,12 @@ export class ScreenDprMonitor extends Disposable { } public clearListener(): void { - if (!this._listener) { + if (!this._resolutionMediaMatchList || !this._listener || !this._outerListener) { return; } this._resolutionMediaMatchList.removeListener(this._outerListener); - this._listener = null; - this._outerListener = null; + this._resolutionMediaMatchList = undefined; + this._listener = undefined; + this._outerListener = undefined; } } diff --git a/src/ui/Types.ts b/src/ui/Types.ts deleted file mode 100644 index 89dfa1a4f..000000000 --- a/src/ui/Types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IDisposable } from 'xterm'; - -export interface IMouseZoneManager extends IDisposable { - add(zone: IMouseZone): void; - clearAll(start?: number, end?: number): void; -} - -export interface IMouseZone { - x1: number; - x2: number; - y1: number; - y2: number; - clickCallback: (e: MouseEvent) => any; - hoverCallback: (e: MouseEvent) => any | undefined; - tooltipCallback: (e: MouseEvent) => any | undefined; - leaveCallback: () => any | undefined; - willLinkActivate: (e: MouseEvent) => boolean; -} diff --git a/src/ui/tsconfig.json b/src/ui/tsconfig.json new file mode 100644 index 000000000..b3613eeff --- /dev/null +++ b/src/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../tsconfig-library-base", + "compilerOptions": { + "lib": [ + "dom", + "es5", + ], + "outDir": "../../lib", + "types": [ + "../../node_modules/@types/mocha" + ] + }, + "include": [ + "./Lifecycle.ts", + "./RenderDebouncer.ts", + "./ScreenDprMonitor.ts" + ], + "references": [ + { "path": "../common" } + ] +} \ No newline at end of file diff --git a/src/xterm.css b/src/xterm.css index e80c25243..5448b5a5a 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -165,3 +165,7 @@ .xterm-dim { opacity: 0.5; } + +.xterm-underline { + text-decoration: underline; +} diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 7f87cb129..f9f5ff845 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -307,6 +307,14 @@ declare module 'xterm' { dispose(): void; } + /** + * An event that can be listened to. + * @returns an `IDisposable` to stop listening. + */ + export interface IEvent { + (listener: (e: T) => any): IDisposable; + } + export interface IMarker extends IDisposable { readonly id: number; readonly isDisposed: boolean; @@ -358,7 +366,7 @@ declare module 'xterm' { * (EXPERIMENTAL) Get all markers registered against the buffer. If the alt * buffer is active this will always return []. */ - readonly markers: IMarker[]; + readonly markers: ReadonlyArray; /** * Natural language strings that can be localized. @@ -372,6 +380,70 @@ declare module 'xterm' { */ constructor(options?: ITerminalOptions); + /** + * Adds an event listener for the cursor moves. + * @returns an `IDisposable` to stop listening. + */ + onCursorMove: IEvent; + + /** + * Adds an event listener for when a data event fires. This happens for + * example when the user types or pastes into the terminal. The event value + * is whatever `string` results, in a typical setup, this should be passed + * on to the backing pty. + * @returns an `IDisposable` to stop listening. + */ + onData: IEvent; + + /** + * Adds an event listener for a key is pressed. The event value contains the + * string that will be sent in the data event as well as the DOM event that + * triggered it. + * @returns an `IDisposable` to stop listening. + */ + onKey: IEvent<{ key: string, domEvent: KeyboardEvent }>; + + /** + * Adds an event listener for when a line feed is added. + * @returns an `IDisposable` to stop listening. + */ + onLineFeed: IEvent; + + /** + * Adds an event listener for when a scroll occurs. The event value is the + * new position of the viewport. + * @returns an `IDisposable` to stop listening. + */ + onScroll: IEvent; + + /** + * Adds an event listener for when a selection change occurs. + * @returns an `IDisposable` to stop listening. + */ + onSelectionChange: IEvent; + + /** + * Adds an event listener for when rows are rendered. The event value + * contains the start row and end rows of the rendered area (ranges from `0` + * to `Terminal.rows - 1`). + * @returns an `IDisposable` to stop listening. + */ + onRender: IEvent<{ start: number, end: number }>; + + /** + * Adds an event listener for when the terminal is resized. The event value + * contains the new size. + * @returns an `IDisposable` to stop listening. + */ + onResize: IEvent<{ cols: number, rows: number }>; + + /** + * Adds an event listener for when an OSC 0 or OSC 2 title change occurs. + * The event value is the new title. + * @returns an `IDisposable` to stop listening. + */ + onTitleChange: IEvent; + /** * Unfocus the terminal. */ @@ -386,54 +458,63 @@ declare module 'xterm' { * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'blur' | 'focus' | 'linefeed' | 'selection', listener: () => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'data', listener: (...args: any[]) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'key', listener: (key: string, event: KeyboardEvent) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'keypress' | 'keydown', listener: (event: KeyboardEvent) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'refresh', listener: (data: {start: number, end: number}) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'resize', listener: (data: {cols: number, rows: number}) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'scroll', listener: (ydisp: number) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: 'title', listener: (title: string) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener)` instead. */ on(type: string, listener: (...args: any[]) => void): void; @@ -441,6 +522,7 @@ declare module 'xterm' { * Deregisters an event listener. * @param type The type of the event. * @param listener The listener. + * @deprecated use `Terminal.onEvent(listener).dispose()` instead. */ off(type: 'blur' | 'focus' | 'linefeed' | 'selection' | 'data' | 'key' | 'keypress' | 'keydown' | 'refresh' | 'resize' | 'scroll' | 'title' | string, listener: (...args: any[]) => void): void; @@ -458,6 +540,7 @@ declare module 'xterm' { * be used to conveniently remove the event listener. * @param type The type of event. * @param handler The event handler. + * @deprecated use `Terminal.onEvent(listener)` instead. */ addDisposableListener(type: string, handler: (...args: any[]) => void): IDisposable; @@ -518,6 +601,7 @@ declare module 'xterm' { * @return An IDisposable you can call to remove this handler. */ addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; + /** * (EXPERIMENTAL) Registers a link matcher, allowing custom link patterns to * be matched and handled.