diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index f68baa82..99bd6d0f 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -1,31 +1,148 @@ /** * @license MIT */ + import { assert } from 'chai'; import { ITerminal } from './Interfaces'; import { Buffer } from './Buffer'; import { CircularList } from './utils/CircularList'; +import { MockTerminal } from './utils/TestUtils'; + +const INIT_COLS = 80; +const INIT_ROWS = 24; describe('Buffer', () => { let terminal: ITerminal; let buffer: Buffer; beforeEach(() => { - terminal = { - cols: 80, - rows: 24, - scrollback: 1000 - }; + terminal = new MockTerminal(); + terminal.cols = INIT_COLS; + terminal.rows = INIT_ROWS; + terminal.options.scrollback = 1000; buffer = new Buffer(terminal); }); describe('constructor', () => { it('should create a CircularList with max length equal to scrollback, for its lines', () => { assert.instanceOf(buffer.lines, CircularList); - assert.equal(buffer.lines.maxLength, terminal.scrollback); + assert.equal(buffer.lines.maxLength, terminal.options.scrollback); }); it('should set the Buffer\'s scrollBottom value equal to the terminal\'s rows -1', () => { assert.equal(buffer.scrollBottom, terminal.rows - 1); }); }); + + describe('fillViewportRows', () => { + it('should fill the buffer with blank lines based on the size of the viewport', () => { + const blankLineChar = terminal.blankLine()[0]; + buffer.fillViewportRows(); + assert.equal(buffer.lines.length, INIT_ROWS); + for (let y = 0; y < INIT_ROWS; y++) { + assert.equal(buffer.lines.get(y).length, INIT_COLS); + for (let x = 0; x < INIT_COLS; x++) { + assert.deepEqual(buffer.lines.get(y)[x], blankLineChar); + } + } + }); + }); + + describe('resize', () => { + describe('column size is reduced', () => { + it('should not trim the data in the buffer', () => { + buffer.fillViewportRows(); + buffer.resize(INIT_COLS / 2, INIT_ROWS); + assert.equal(buffer.lines.length, INIT_ROWS); + for (let i = 0; i < INIT_ROWS; i++) { + assert.equal(buffer.lines.get(i).length, INIT_COLS); + } + }); + }); + + describe('column size is increased', () => { + it('should add pad columns', () => { + buffer.fillViewportRows(); + buffer.resize(INIT_COLS + 10, INIT_ROWS); + assert.equal(buffer.lines.length, INIT_ROWS); + for (let i = 0; i < INIT_ROWS; i++) { + assert.equal(buffer.lines.get(i).length, INIT_COLS + 10); + } + }); + }); + + describe('row size reduced', () => { + it('should trim blank lines from the end', () => { + buffer.fillViewportRows(); + buffer.resize(INIT_COLS, INIT_ROWS - 10); + assert.equal(buffer.lines.length, INIT_ROWS - 10); + }); + + it('should move the viewport down when it\'s at the end', () => { + buffer.fillViewportRows(); + // Set cursor y to have 5 blank lines below it + buffer.y = INIT_ROWS - 5 - 1; + buffer.resize(INIT_COLS, INIT_ROWS - 10); + // Trim 5 rows + assert.equal(buffer.lines.length, INIT_ROWS - 5); + // Shift the viewport down 5 rows + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 5); + }); + }); + + describe('row size increased', () => { + describe('empty buffer', () => { + it('should add blank lines to end', () => { + buffer.fillViewportRows(); + assert.equal(buffer.ydisp, 0); + buffer.resize(INIT_COLS, INIT_ROWS + 10); + assert.equal(buffer.ydisp, 0); + assert.equal(buffer.lines.length, INIT_ROWS + 10); + }); + }); + + describe('filled buffer', () => { + it('should show more of the buffer above', () => { + buffer.fillViewportRows(); + // Create 10 extra blank lines + for (let i = 0; i < 10; i++) { + buffer.lines.push(terminal.blankLine()); + } + // Set cursor to the bottom of the buffer + buffer.y = INIT_ROWS - 1; + // Scroll down 10 lines + buffer.ybase = 10; + buffer.ydisp = 10; + assert.equal(buffer.lines.length, INIT_ROWS + 10); + buffer.resize(INIT_COLS, INIT_ROWS + 5); + // Should be should 5 more lines + assert.equal(buffer.ydisp, 5); + assert.equal(buffer.ybase, 5); + // Should not trim the buffer + assert.equal(buffer.lines.length, INIT_ROWS + 10); + }); + + it('should show more of the buffer below when the viewport is at the top of the buffer', () => { + buffer.fillViewportRows(); + // Create 10 extra blank lines + for (let i = 0; i < 10; i++) { + buffer.lines.push(terminal.blankLine()); + } + // Set cursor to the bottom of the buffer + buffer.y = INIT_ROWS - 1; + // Scroll down 10 lines + buffer.ybase = 10; + buffer.ydisp = 0; + assert.equal(buffer.lines.length, INIT_ROWS + 10); + buffer.resize(INIT_COLS, INIT_ROWS + 5); + // The viewport should remain at the top + assert.equal(buffer.ydisp, 0); + // The buffer ybase should move up 5 lines + assert.equal(buffer.ybase, 5); + // Should not trim the buffer + assert.equal(buffer.lines.length, INIT_ROWS + 10); + }); + }); + }); + }); }); diff --git a/src/Buffer.ts b/src/Buffer.ts index 12b2137d..9a28438d 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -2,8 +2,9 @@ * @license MIT */ -import { ITerminal } from './Interfaces'; +import { ITerminal, IBuffer } from './Interfaces'; import { CircularList } from './utils/CircularList'; +import { LineData, CharData } from './Types'; /** * This class represents a terminal buffer (an internal state of the terminal), where the @@ -12,31 +13,126 @@ import { CircularList } from './utils/CircularList'; * - cursor position * - scroll position */ -export class Buffer { - public lines: CircularList<[number, string, number][]>; +export class Buffer implements IBuffer { + private _lines: CircularList; + public ydisp: number; + public ybase: number; + public y: number; + public x: number; + public scrollBottom: number; + public scrollTop: number; + public tabs: any; public savedY: number; public savedX: number; /** * Create a new Buffer. - * @param {Terminal} terminal - The terminal the Buffer will belong to + * @param {Terminal} _terminal - The terminal the Buffer will belong to * @param {number} ydisp - The scroll position of the Buffer in the viewport * @param {number} ybase - The scroll position of the y cursor (ybase + y = the y position within the Buffer) * @param {number} y - The cursor's y position after ybase * @param {number} x - The cursor's x position after ybase */ constructor( - private terminal: ITerminal, - public ydisp: number = 0, - public ybase: number = 0, - public y: number = 0, - public x: number = 0, - public scrollBottom: number = 0, - public scrollTop: number = 0, - public tabs: any = {}, + private _terminal: ITerminal ) { - this.lines = new CircularList<[number, string, number][]>(this.terminal.scrollback); - this.scrollBottom = this.terminal.rows - 1; + this.clear(); + } + + public get lines(): CircularList { + return this._lines; + } + + public fillViewportRows(): void { + if (this._lines.length === 0) { + let i = this._terminal.rows; + while (i--) { + this.lines.push(this._terminal.blankLine()); + } + } + } + + public clear(): void { + this.ydisp = 0; + this.ybase = 0; + this.y = 0; + this.x = 0; + this.scrollBottom = 0; + this.scrollTop = 0; + this.tabs = {}; + this._lines = new CircularList(this._terminal.options.scrollback); + this.scrollBottom = this._terminal.rows - 1; + } + + public resize(newCols: number, newRows: number): void { + // Don't resize the buffer if it's empty and hasn't been used yet. + if (this._lines.length === 0) { + return; + } + + // Deal with columns increasing (we don't do anything when columns reduce) + if (this._terminal.cols < newCols) { + const ch: CharData = [this._terminal.defAttr, ' ', 1]; // does xterm use the default attr? + for (let i = 0; i < this._lines.length; i++) { + if (this._lines.get(i) === undefined) { + this._lines.set(i, this._terminal.blankLine()); + } + while (this._lines.get(i).length < newCols) { + this._lines.get(i).push(ch); + } + } + } + + // Resize rows in both directions as needed + let addToY = 0; + if (this._terminal.rows < newRows) { + for (let y = this._terminal.rows; y < newRows; y++) { + if (this._lines.length < newRows + this.ybase) { + if (this.ybase > 0 && this._lines.length <= this.ybase + this.y + addToY + 1) { + // There is room above the buffer and there are no empty elements below the line, + // scroll up + this.ybase--; + addToY++; + if (this.ydisp > 0) { + // Viewport is at the top of the buffer, must increase downwards + this.ydisp--; + } + } else { + // Add a blank line if there is no buffer left at the top to scroll to, or if there + // are blank lines after the cursor + this._lines.push(this._terminal.blankLine()); + } + } + } + } else { // (this._terminal.rows >= newRows) + for (let y = this._terminal.rows; y > newRows; y--) { + if (this._lines.length > newRows + this.ybase) { + if (this._lines.length > this.ybase + this.y + 1) { + // The line is a blank line below the cursor, remove it + this._lines.pop(); + } else { + // The line is the cursor, scroll down + this.ybase++; + this.ydisp++; + } + } + } + } + + // Make sure that the cursor stays on screen + if (this.y >= newRows) { + this.y = newRows - 1; + } + if (addToY) { + this.y += addToY; + } + + if (this.x >= newCols) { + this.x = newCols - 1; + } + + this.scrollTop = 0; + this.scrollBottom = newRows - 1; } } diff --git a/src/BufferSet.test.ts b/src/BufferSet.test.ts index 2101fbc1..97c07995 100644 --- a/src/BufferSet.test.ts +++ b/src/BufferSet.test.ts @@ -1,21 +1,22 @@ /** * @license MIT */ + import { assert } from 'chai'; import { ITerminal } from './Interfaces'; import { BufferSet } from './BufferSet'; import { Buffer } from './Buffer'; +import { MockTerminal } from './utils/TestUtils'; describe('BufferSet', () => { let terminal: ITerminal; let bufferSet: BufferSet; beforeEach(() => { - terminal = { - cols: 80, - rows: 24, - scrollback: 1000 - }; + terminal = new MockTerminal(); + terminal.cols = 80; + terminal.rows = 24; + terminal.options.scrollback = 1000; bufferSet = new BufferSet(terminal); }); diff --git a/src/BufferSet.ts b/src/BufferSet.ts index e86c098f..345191cf 100644 --- a/src/BufferSet.ts +++ b/src/BufferSet.ts @@ -22,6 +22,7 @@ export class BufferSet extends EventEmitter implements IBufferSet { constructor(private _terminal: ITerminal) { super(); this._normal = new Buffer(this._terminal); + this._normal.fillViewportRows(); this._alt = new Buffer(this._terminal); this._activeBuffer = this._normal; } @@ -54,6 +55,11 @@ export class BufferSet extends EventEmitter implements IBufferSet { * Sets the normal Buffer of the BufferSet as its currently active Buffer */ public activateNormalBuffer(): void { + // The alt buffer should always be cleared when we switch to the normal + // buffer. This frees up memory since the alt buffer should always be new + // when activated. + this._alt.clear(); + this._activeBuffer = this._normal; this.emit('activate', this._normal); } @@ -62,7 +68,16 @@ export class BufferSet extends EventEmitter implements IBufferSet { * Sets the alt Buffer of the BufferSet as its currently active Buffer */ public activateAltBuffer(): void { + // Since the alt buffer is always cleared when the normal buffer is + // activated, we want to fill it when switching to it. + this._alt.fillViewportRows(); + this._activeBuffer = this._alt; this.emit('activate', this._alt); } + + public resize(newCols: number, newRows: number): void { + this._normal.resize(newCols, newRows); + this._alt.resize(newCols, newRows); + } } diff --git a/src/Charsets.ts b/src/Charsets.ts index f567ef44..c2c92e8d 100644 --- a/src/Charsets.ts +++ b/src/Charsets.ts @@ -2,17 +2,19 @@ * @license MIT */ +import { Charset } from './Types'; + /** * The character sets supported by the terminal. These enable several languages * to be represented within the terminal with only 8-bit encoding. See ISO 2022 * for a discussion on character sets. Only VT100 character sets are supported. */ -export const CHARSETS: {[key: string]: {[key: string]: string}} = {}; +export const CHARSETS: { [key: string]: Charset } = {}; /** * The default character set, US. */ -export const DEFAULT_CHARSET = CHARSETS['B']; +export const DEFAULT_CHARSET: Charset = CHARSETS['B']; /** * DEC Special Character and Line Drawing Set. diff --git a/src/CompositionHelper.test.ts b/src/CompositionHelper.test.ts index 4a96e12a..0adb1719 100644 --- a/src/CompositionHelper.test.ts +++ b/src/CompositionHelper.test.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { CompositionHelper } from './CompositionHelper'; @@ -36,7 +40,7 @@ describe('CompositionHelper', () => { return { offsetLeft: 0, offsetTop: 0 }; } }, - handler: function (text) { + handler: (text: string) => { handledText += text; } }; @@ -45,7 +49,7 @@ describe('CompositionHelper', () => { }); describe('Input', () => { - it('Should insert simple characters', function (done) { + it('Should insert simple characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); @@ -69,7 +73,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert complex characters', function (done) { + it('Should insert complex characters', (done) => { // First character '앙' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); @@ -109,7 +113,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert complex characters that change with following character', function (done) { + it('Should insert complex characters that change with following character', (done) => { // First character '아' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); @@ -138,7 +142,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert multi-characters compositions', function (done) { + it('Should insert multi-characters compositions', (done) => { // First character 'だ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'd' }); @@ -161,7 +165,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert multi-character compositions that are converted to other characters with the same length', function (done) { + it('Should insert multi-character compositions that are converted to other characters with the same length', (done) => { // First character 'だ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'd' }); @@ -189,7 +193,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert multi-character compositions that are converted to other characters with different lengths', function (done) { + it('Should insert multi-character compositions that are converted to other characters with different lengths', (done) => { // First character 'い' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'い' }); @@ -217,7 +221,7 @@ describe('CompositionHelper', () => { }, 0); }); - it('Should insert non-composition characters input immediately after composition characters', function (done) { + it('Should insert non-composition characters input immediately after composition characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); diff --git a/src/CompositionHelper.ts b/src/CompositionHelper.ts index 439e2085..223a90e7 100644 --- a/src/CompositionHelper.ts +++ b/src/CompositionHelper.ts @@ -51,7 +51,7 @@ export class CompositionHelper { /** * Handles the compositionstart event, activating the composition view. */ - public compositionstart() { + public compositionstart(): void { this.isComposing = true; this.compositionPosition.start = this.textarea.value.length; this.compositionView.textContent = ''; @@ -62,7 +62,7 @@ export class CompositionHelper { * Handles the compositionupdate event, updating the composition view. * @param {CompositionEvent} ev The event. */ - public compositionupdate(ev: CompositionEvent) { + public compositionupdate(ev: CompositionEvent): void { this.compositionView.textContent = ev.data; this.updateCompositionElements(); setTimeout(() => { @@ -74,7 +74,7 @@ export class CompositionHelper { * Handles the compositionend event, hiding the composition view and sending the composition to * the handler. */ - public compositionend() { + public compositionend(): void { this.finalizeComposition(true); } @@ -83,7 +83,7 @@ export class CompositionHelper { * @param ev The keydown event. * @return Whether the Terminal should continue processing the keydown event. */ - public keydown(ev: KeyboardEvent) { + public keydown(ev: KeyboardEvent): boolean { if (this.isComposing || this.isSendingComposition) { if (ev.keyCode === 229) { // Continue composing if the keyCode is the "composition character" @@ -116,7 +116,7 @@ export class CompositionHelper { * compositionend event is triggered, such as enter, so that the composition is send before * the command is executed. */ - private finalizeComposition(waitForPropogation: boolean) { + private finalizeComposition(waitForPropogation: boolean): void { this.compositionView.classList.remove('active'); this.isComposing = false; this.clearTextareaPosition(); @@ -169,7 +169,7 @@ export class CompositionHelper { * character" (229) is triggered, in order to allow non-composition text to be entered when an * IME is active. */ - private handleAnyTextareaChanges() { + private handleAnyTextareaChanges(): void { const oldValue = this.textarea.value; setTimeout(() => { // Ignore if a composition has started since the timeout @@ -189,7 +189,7 @@ export class CompositionHelper { * @param dontRecurse Whether to use setTimeout to recursively trigger another update, this is * necessary as the IME events across browsers are not consistently triggered. */ - public updateCompositionElements(dontRecurse?: boolean) { + public updateCompositionElements(dontRecurse?: boolean): void { if (!this.isComposing) { return; } @@ -222,7 +222,7 @@ export class CompositionHelper { * Clears the textarea's position so that the cursor does not blink on IE. * @private */ - private clearTextareaPosition() { + private clearTextareaPosition(): void { this.textarea.style.left = ''; this.textarea.style.top = ''; }; diff --git a/src/EventEmitter.test.ts b/src/EventEmitter.test.ts index 7ae381c8..d61f994a 100644 --- a/src/EventEmitter.test.ts +++ b/src/EventEmitter.test.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { EventEmitter } from './EventEmitter'; diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 00c10941..a1768d2d 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -2,15 +2,10 @@ * @license MIT */ -import { IEventEmitter } from './Interfaces'; - -interface ListenerType { - (): void; - listener?: () => void; -}; +import { IEventEmitter, IListenerType } from './Interfaces'; export class EventEmitter implements IEventEmitter { - private _events: {[type: string]: ListenerType[]}; + private _events: {[type: string]: IListenerType[]}; constructor() { // Restore the previous events if available, this will happen if the @@ -18,12 +13,12 @@ export class EventEmitter implements IEventEmitter { this._events = this._events || {}; } - public on(type, listener): void { + public on(type: string, listener: IListenerType): void { this._events[type] = this._events[type] || []; this._events[type].push(listener); } - public off(type, listener): void { + public off(type: string, listener: IListenerType): void { if (!this._events[type]) { return; } @@ -39,20 +34,20 @@ export class EventEmitter implements IEventEmitter { } } - public removeAllListeners(type): void { + public removeAllListeners(type: string): void { if (this._events[type]) { delete this._events[type]; } } - public once(type, listener): any { - function on() { + public once(type: string, listener: IListenerType): void { + function on(): void { let args = Array.prototype.slice.call(arguments); this.off(type, on); - return listener.apply(this, args); + listener.apply(this, args); } (on).listener = listener; - return this.on(type, on); + this.on(type, on); } public emit(type: string, ...args: any[]): void { @@ -65,7 +60,11 @@ export class EventEmitter implements IEventEmitter { } } - public listeners(type): ListenerType[] { + public listeners(type: string): IListenerType[] { return this._events[type] || []; } + + protected destroy(): void { + this._events = {}; + } } diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index 158c07c9..043b7919 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -1,10 +1,17 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { InputHandler } from './InputHandler'; import { wcwidth } from './InputHandler'; +import { MockInputHandlingTerminal } from './utils/TestUtils'; describe('InputHandler', () => { describe('save and restore cursor', () => { - let terminal = { buffer: { x: 1, y: 2 } }; + let terminal = new MockInputHandlingTerminal(); + terminal.buffer.x = 1; + terminal.buffer.y = 2; let inputHandler = new InputHandler(terminal); // Save cursor position inputHandler.saveCursor([]); @@ -20,51 +27,47 @@ describe('InputHandler', () => { }); describe('setCursorStyle', () => { it('should call Terminal.setOption with correct params', () => { - let options = {}; - let terminal = { - setOption: (option, value) => options[option] = value - }; + let terminal = new MockInputHandlingTerminal(); let inputHandler = new InputHandler(terminal); inputHandler.setCursorStyle([0]); - assert.equal(options['cursorStyle'], 'block'); - assert.equal(options['cursorBlink'], true); + assert.equal(terminal.options['cursorStyle'], 'block'); + assert.equal(terminal.options['cursorBlink'], true); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([1]); - assert.equal(options['cursorStyle'], 'block'); - assert.equal(options['cursorBlink'], true); + assert.equal(terminal.options['cursorStyle'], 'block'); + assert.equal(terminal.options['cursorBlink'], true); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([2]); - assert.equal(options['cursorStyle'], 'block'); - assert.equal(options['cursorBlink'], false); + assert.equal(terminal.options['cursorStyle'], 'block'); + assert.equal(terminal.options['cursorBlink'], false); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([3]); - assert.equal(options['cursorStyle'], 'underline'); - assert.equal(options['cursorBlink'], true); + assert.equal(terminal.options['cursorStyle'], 'underline'); + assert.equal(terminal.options['cursorBlink'], true); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([4]); - assert.equal(options['cursorStyle'], 'underline'); - assert.equal(options['cursorBlink'], false); + assert.equal(terminal.options['cursorStyle'], 'underline'); + assert.equal(terminal.options['cursorBlink'], false); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([5]); - assert.equal(options['cursorStyle'], 'bar'); - assert.equal(options['cursorBlink'], true); + assert.equal(terminal.options['cursorStyle'], 'bar'); + assert.equal(terminal.options['cursorBlink'], true); - options = {}; + terminal.options = {}; inputHandler.setCursorStyle([6]); - assert.equal(options['cursorStyle'], 'bar'); - assert.equal(options['cursorBlink'], false); - + assert.equal(terminal.options['cursorStyle'], 'bar'); + assert.equal(terminal.options['cursorBlink'], false); }); }); }); -const old_wcwidth = (function(opts) { +const old_wcwidth = (function(opts: {nul: number, control: number}): (ucs: number) => number { // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c // combining characters const COMBINING = [ @@ -118,7 +121,7 @@ const old_wcwidth = (function(opts) { [0xE0100, 0xE01EF] ]; // binary search - function bisearch(ucs) { + function bisearch(ucs: number): boolean { let min = 0; let max = COMBINING.length - 1; let mid; @@ -135,23 +138,26 @@ const old_wcwidth = (function(opts) { } return false; } - function wcwidth(ucs) { - // test for 8-bit control characters - if (ucs === 0) - return opts.nul; - if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) - return opts.control; - // binary search in table of non-spacing characters - if (bisearch(ucs)) - return 0; - // if we arrive here, ucs is not a combining or C0/C1 control character - if (isWide(ucs)) { - return 2; - } - return 1; + function wcwidth(ucs: number): number { + // test for 8-bit control characters + if (ucs === 0) { + return opts.nul; + } + if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { + return opts.control; + } + // binary search in table of non-spacing characters + if (bisearch(ucs)) { + return 0; + } + // if we arrive here, ucs is not a combining or C0/C1 control character + if (isWide(ucs)) { + return 2; + } + return 1; } - function isWide(ucs) { - return ( + function isWide(ucs: number): boolean { + return ( ucs >= 0x1100 && ( ucs <= 0x115f || // Hangul Jamo init. consonants ucs === 0x2329 || diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 64da3b3f..5362cae9 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -2,9 +2,10 @@ * @license MIT */ -import { IInputHandler, ITerminal } from './Interfaces'; +import { IInputHandler, ITerminal, IInputHandlingTerminal } from './Interfaces'; import { C0 } from './EscapeSequences'; import { DEFAULT_CHARSET } from './Charsets'; +import { CharData } from './Types'; /** * The terminal's standard implementation of IInputHandler, this handles all @@ -14,8 +15,7 @@ import { DEFAULT_CHARSET } from './Charsets'; * each function's header comment. */ export class InputHandler implements IInputHandler { - // TODO: We want to type _terminal when it's pulled into TS - constructor(private _terminal: any) { } + constructor(private _terminal: IInputHandlingTerminal) { } public addChar(char: string, code: number): void { if (char >= ' ') { @@ -61,7 +61,7 @@ export class InputHandler implements IInputHandler { } else { // The line already exists (eg. the initial viewport), mark it as a // wrapped line - this._terminal.buffer.lines.get(this._terminal.buffer.y).isWrapped = true; + (this._terminal.buffer.lines.get(this._terminal.buffer.y)).isWrapped = true; } } else { if (ch_width === 2) // FIXME: check for xterm behavior @@ -105,12 +105,12 @@ export class InputHandler implements IInputHandler { * Bell (Ctrl-G). */ public bell(): void { - if (!this._terminal.visualBell) { + if (!this._terminal.options.visualBell) { return; } this._terminal.element.style.borderColor = 'white'; setTimeout(() => this._terminal.element.style.borderColor = '', 10); - if (this._terminal.popOnBell) { + if (this._terminal.options.popOnBell) { this._terminal.focus(); } } @@ -189,14 +189,12 @@ export class InputHandler implements IInputHandler { * Insert Ps (Blank) Character(s) (default = 1) (ICH). */ public insertChars(params: number[]): void { - let param, row, j, ch; - - param = params[0]; + let param = params[0]; if (param < 1) param = 1; - row = this._terminal.buffer.y + this._terminal.buffer.ybase; - j = this._terminal.buffer.x; - ch = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const row = this._terminal.buffer.y + this._terminal.buffer.ybase; + let j = this._terminal.buffer.x; + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row).splice(j++, 0, ch); @@ -223,7 +221,7 @@ export class InputHandler implements IInputHandler { * CSI Ps B * Cursor Down Ps Times (default = 1) (CUD). */ - public cursorDown(params: number[]) { + public cursorDown(params: number[]): void { let param = params[0]; if (param < 1) { param = 1; @@ -242,7 +240,7 @@ export class InputHandler implements IInputHandler { * CSI Ps C * Cursor Forward Ps Times (default = 1) (CUF). */ - public cursorForward(params: number[]) { + public cursorForward(params: number[]): void { let param = params[0]; if (param < 1) { param = 1; @@ -257,7 +255,7 @@ export class InputHandler implements IInputHandler { * CSI Ps D * Cursor Backward Ps Times (default = 1) (CUB). */ - public cursorBackward(params: number[]) { + public cursorBackward(params: number[]): void { let param = params[0]; if (param < 1) { param = 1; @@ -325,9 +323,8 @@ export class InputHandler implements IInputHandler { * Cursor Position [row;column] (default = [1,1]) (CUP). */ public cursorPosition(params: number[]): void { - let row, col; - - row = params[0] - 1; + let col: number; + let row: number = params[0] - 1; if (params.length >= 2) { col = params[1] - 1; @@ -439,14 +436,13 @@ export class InputHandler implements IInputHandler { * Insert Ps Line(s) (default = 1) (IL). */ public insertLines(params: number[]): void { - let param, row, j; - - param = params[0]; + let param: number = params[0]; if (param < 1) { param = 1; } - row = this._terminal.buffer.y + this._terminal.buffer.ybase; + let row: number = this._terminal.buffer.y + this._terminal.buffer.ybase; + let j: number; j = this._terminal.rows - 1 - this._terminal.buffer.scrollBottom; j = this._terminal.rows - 1 + this._terminal.buffer.ybase - j + 1; @@ -475,14 +471,13 @@ export class InputHandler implements IInputHandler { * Delete Ps Line(s) (default = 1) (DL). */ public deleteLines(params: number[]): void { - let param, row, j; - - param = params[0]; + let param = params[0]; if (param < 1) { param = 1; } - row = this._terminal.buffer.y + this._terminal.buffer.ybase; + const row: number = this._terminal.buffer.y + this._terminal.buffer.ybase; + let j: number; j = this._terminal.rows - 1 - this._terminal.buffer.scrollBottom; j = this._terminal.rows - 1 + this._terminal.buffer.ybase - j; @@ -509,15 +504,13 @@ export class InputHandler implements IInputHandler { * Delete Ps Character(s) (default = 1) (DCH). */ public deleteChars(params: number[]): void { - let param, row, ch; - - param = params[0]; + let param: number = params[0]; if (param < 1) { param = 1; } - row = this._terminal.buffer.y + this._terminal.buffer.ybase; - ch = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const row = this._terminal.buffer.y + this._terminal.buffer.ybase; + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm while (param--) { this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 1); @@ -558,16 +551,14 @@ export class InputHandler implements IInputHandler { * Erase Ps Character(s) (default = 1) (ECH). */ public eraseChars(params: number[]): void { - let param, row, j, ch; - - param = params[0]; + let param = params[0]; if (param < 1) { param = 1; } - row = this._terminal.buffer.y + this._terminal.buffer.ybase; - j = this._terminal.buffer.x; - ch = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const row = this._terminal.buffer.y + this._terminal.buffer.ybase; + let j = this._terminal.buffer.x; + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row)[j++] = ch; @@ -619,9 +610,9 @@ export class InputHandler implements IInputHandler { * CSI Ps b Repeat the preceding graphic character Ps times (REP). */ public repeatPrecedingCharacter(params: number[]): void { - let param = params[0] || 1 - , line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y) - , ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1]; + let param = params[0] || 1; + const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y); + const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1]; while (param--) { line[this._terminal.buffer.x++] = ch; @@ -867,7 +858,7 @@ export class InputHandler implements IInputHandler { this._terminal.insertMode = true; break; case 20: - // this._terminal.convertEol = true; + // this._t.convertEol = true; break; } } else if (this._terminal.prefix === '?') { @@ -954,7 +945,6 @@ export class InputHandler implements IInputHandler { case 47: // alt screen buffer case 1047: // alt screen buffer this._terminal.buffers.activateAltBuffer(); - this._terminal.reset(); this._terminal.viewport.syncScrollArea(); this._terminal.showCursor(); break; @@ -1059,7 +1049,7 @@ export class InputHandler implements IInputHandler { this._terminal.insertMode = false; break; case 20: - // this._terminal.convertEol = false; + // this._t.convertEol = false; break; } } else if (this._terminal.prefix === '?') { @@ -1203,14 +1193,13 @@ export class InputHandler implements IInputHandler { return; } - let l = params.length - , i = 0 - , flags = this._terminal.curAttr >> 18 - , fg = (this._terminal.curAttr >> 9) & 0x1ff - , bg = this._terminal.curAttr & 0x1ff - , p; + const l = params.length; + let flags = this._terminal.curAttr >> 18; + let fg = (this._terminal.curAttr >> 9) & 0x1ff; + let bg = this._terminal.curAttr & 0x1ff; + let p; - for (; i < l; i++) { + for (let i = 0; i < l; i++) { p = params[i]; if (p >= 30 && p <= 37) { // fg color 8 @@ -1470,7 +1459,7 @@ export class InputHandler implements IInputHandler { } } -export const wcwidth = (function(opts) { +export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: number) => number { // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c // combining characters const COMBINING_BMP = [ @@ -1526,7 +1515,7 @@ export const wcwidth = (function(opts) { [0xE0100, 0xE01EF] ]; // binary search - function bisearch(ucs, data) { + function bisearch(ucs: number, data: number[][]): boolean { let min = 0; let max = data.length - 1; let mid; @@ -1543,7 +1532,7 @@ export const wcwidth = (function(opts) { } return false; } - function wcwidthBMP(ucs) { + function wcwidthBMP(ucs: number): number { // test for 8-bit control characters if (ucs === 0) return opts.nul; @@ -1558,7 +1547,7 @@ export const wcwidth = (function(opts) { } return 1; } - function isWideBMP(ucs) { + function isWideBMP(ucs: number): boolean { return ( ucs >= 0x1100 && ( ucs <= 0x115f || // Hangul Jamo init. consonants @@ -1572,7 +1561,7 @@ export const wcwidth = (function(opts) { (ucs >= 0xff00 && ucs <= 0xff60) || // Fullwidth Forms (ucs >= 0xffe0 && ucs <= 0xffe6))); } - function wcwidthHigh(ucs) { + function wcwidthHigh(ucs: number): 0 | 1 | 2 { if (bisearch(ucs, COMBINING_HIGH)) return 0; if ((ucs >= 0x20000 && ucs <= 0x2fffd) || (ucs >= 0x30000 && ucs <= 0x3fffd)) { @@ -1581,8 +1570,8 @@ export const wcwidth = (function(opts) { return 1; } const control = opts.control | 0; - let table = null; - function init_table() { + let table: number[] | Uint32Array = null; + function init_table(): number[] | Uint32Array { // lookup table for BMP const CODEPOINTS = 65536; // BMP holds 65536 codepoints const BITWIDTH = 2; // a codepoint can have a width of 0, 1 or 2 @@ -1599,7 +1588,7 @@ export const wcwidth = (function(opts) { num = (num << 2) | wcwidthBMP(CODEPOINTS_PER_ITEM * i + pos); table[i] = num; } - return table; + return table; } // get width from lookup table // position in container : num / CODEPOINTS_PER_ITEM @@ -1613,7 +1602,7 @@ export const wcwidth = (function(opts) { // ==> n = n >> m e.g. m=12 000000000000FFEEDDCCBBAA99887766 // we are only interested in 2 LSBs, cut off higher bits // ==> n = n & 3 e.g. 000000000000000000000000000000XX - return function (num) { + return function (num: number): number { num = num | 0; // get asm.js like optimization under V8 if (num < 32) return control | 0; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index f19a7f28..55317fbc 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -2,8 +2,8 @@ * @license MIT */ -import { LinkMatcherOptions } from './Interfaces'; -import { LinkMatcherHandler, LinkMatcherValidationCallback } from './Types'; +import { ILinkMatcherOptions } from './Interfaces'; +import { LinkMatcherHandler, LinkMatcherValidationCallback, Charset, LineData } from './Types'; export interface IBrowser { isNode: boolean; @@ -17,7 +17,7 @@ export interface IBrowser { isMSWindows: boolean; } -export interface ITerminal { +export interface ITerminal extends IEventEmitter { element: HTMLElement; rowContainer: HTMLElement; selectionContainer: HTMLElement; @@ -32,7 +32,7 @@ export interface ITerminal { cursorHidden: boolean; cursorState: number; defAttr: number; - scrollback: number; + options: ITerminalOptions; buffers: IBufferSet; buffer: IBuffer; @@ -40,23 +40,110 @@ export interface ITerminal { * Emit the 'data' event and populate the given data. * @param data The data to populate in the event. */ - handler(data: string); - on(event: string, callback: () => void); - scrollDisp(disp: number, suppressScrollEvent: boolean); - cancel(ev: Event, force?: boolean); + handler(data: string): void; + scrollDisp(disp: number, suppressScrollEvent?: boolean): void; + cancel(ev: Event, force?: boolean): boolean | void; log(text: string): void; - emit(event: string, data: any); reset(): void; showCursor(): void; + blankLine(cur?: boolean, isWrapped?: boolean): LineData; +} + +/** + * This interface encapsulates everything needed from the Terminal by the + * InputHandler. This cleanly separates the large amount of methods needed by + * InputHandler cleanly from the ITerminal interface. + */ +export interface IInputHandlingTerminal extends IEventEmitter { + element: HTMLElement; + options: ITerminalOptions; + cols: number; + rows: number; + charset: Charset; + gcharset: number; + glevel: number; + charsets: Charset[]; + applicationKeypad: boolean; + applicationCursor: boolean; + originMode: boolean; + insertMode: boolean; + wraparoundMode: boolean; + defAttr: number; + curAttr: number; + prefix: string; + savedCols: number; + x10Mouse: boolean; + vt200Mouse: boolean; + normalMouse: boolean; + mouseEvents: boolean; + sendFocus: boolean; + utfMouse: boolean; + sgrMouse: boolean; + urxvtMouse: boolean; + cursorHidden: boolean; + + buffers: IBufferSet; + buffer: IBuffer; + viewport: IViewport; + selectionManager: ISelectionManager; + + focus(): void; + convertEol: boolean; + updateRange(y: number): void; + scroll(isWrapped?: boolean): void; + nextStop(x?: number): number; + setgLevel(g: number): void; + eraseAttr(): number; + eraseRight(x: number, y: number): void; + eraseLine(y: number): void; + eraseLeft(x: number, y: number): void; + blankLine(cur?: boolean, isWrapped?: boolean): LineData; + prevStop(x?: number): number; + is(term: string): boolean; + send(data: string): void; + setgCharset(g: number, charset: Charset): void; + resize(x: number, y: number): void; + log(text: string, data?: any): void; + reset(): void; + showCursor(): void; + refresh(start: number, end: number): void; + matchColor(r1: number, g1: number, b1: number): number; + error(text: string, data?: any): void; + setOption(key: string, value: any): void; +} + +export interface ITerminalOptions { + cancelEvents?: boolean; + colors?: string[]; + cols?: number; + convertEol?: boolean; + cursorBlink?: boolean; + cursorStyle?: string; + debug?: boolean; + disableStdin?: boolean; + geometry?: [number, number]; + handler?: (data: string) => void; + popOnBell?: boolean; + rows?: number; + screenKeys?: boolean; + scrollback?: number; + tabStopWidth?: number; + termName?: string; + useFlowControl?: boolean; + visualBell?: boolean; } export interface IBuffer { - lines: ICircularList<[number, string, number][]>; + lines: ICircularList; ydisp: number; ybase: number; y: number; x: number; tabs: any; + scrollBottom: number; + scrollTop: number; + savedY: number; + savedX: number; } export interface IBufferSet { @@ -68,12 +155,19 @@ export interface IBufferSet { activateAltBuffer(): void; } +export interface IViewport { + syncScrollArea(): void; +} + export interface ISelectionManager { selectionText: string; selectionStart: [number, number]; selectionEnd: [number, number]; - setSelection(row: number, col: number, length: number); + disable(): void; + enable(): void; + setBuffer(buffer: ICircularList): void; + setSelection(row: number, col: number, length: number): void; } export interface ICharMeasure { @@ -85,15 +179,15 @@ export interface ICharMeasure { export interface ILinkifier { linkifyRow(rowIndex: number): void; attachHypertextLinkHandler(handler: LinkMatcherHandler): void; - registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: LinkMatcherOptions): number; + registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number; deregisterLinkMatcher(matcherId: number): boolean; } export interface ICircularList extends IEventEmitter { length: number; maxLength: number; + forEach: (callbackfn: (value: T, index: number) => void) => void; - forEach(callbackfn: (value: T, index: number, array: T[]) => void): void; get(index: number): T; set(index: number, value: T): void; push(value: T): void; @@ -104,11 +198,17 @@ export interface ICircularList extends IEventEmitter { } export interface IEventEmitter { - on(type, listener): void; - off(type, listener): void; + on(type: string, listener: IListenerType): void; + off(type: string, listener: IListenerType): void; + emit(type: string, data?: any): void; } -export interface LinkMatcherOptions { +export interface IListenerType { + (data?: any): void; + listener?: (data?: any) => void; +}; + +export interface ILinkMatcherOptions { /** * The index of the link from the regex.match(text) call. This defaults to 0 * (for regular expressions without capture groups). diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index 132ce5f0..a4ed70db 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -1,6 +1,7 @@ /** * @license MIT */ + import jsdom = require('jsdom'); import { assert } from 'chai'; import { ITerminal, ILinkifier } from './Interfaces'; @@ -32,7 +33,7 @@ describe('Linkifier', () => { linkifier = new TestLinkifier(); }); - function addRow(html: string) { + function addRow(html: string): void { const element = document.createElement('div'); element.innerHTML = html; container.appendChild(element); @@ -57,24 +58,24 @@ describe('Linkifier', () => { document.body.appendChild(container); }); - function clickElement(element: Node) { + function clickElement(element: Node): void { const event = document.createEvent('MouseEvent'); event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); element.dispatchEvent(event); } - function assertLinkifiesEntireRow(uri: string, done: MochaDone) { - addRow(uri); - linkifier.linkifyRow(0); - setTimeout(() => { - assert.equal((rows[0].firstChild).tagName, 'A'); - assert.equal((rows[0].firstChild).textContent, uri); - done(); - }, 0); + function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { + addRow(uri); + linkifier.linkifyRow(0); + setTimeout(() => { + assert.equal((rows[0].firstChild).tagName, 'A'); + assert.equal((rows[0].firstChild).textContent, uri); + done(); + }, 0); } describe('http links', () => { - function assertLinkifiesEntireRow(uri: string, done: MochaDone) { + function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { addRow(uri); linkifier.linkifyRow(0); setTimeout(() => { @@ -87,7 +88,7 @@ describe('Linkifier', () => { }); describe('link matcher', () => { - function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, expectedHtml: string, done: MochaDone) { + function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, expectedHtml: string, done: MochaDone): void { addRow(rowText); linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); linkifier.linkifyRow(0); diff --git a/src/Linkifier.ts b/src/Linkifier.ts index bc4949b1..2323f226 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -2,7 +2,7 @@ * @license MIT */ -import { LinkMatcherOptions } from './Interfaces'; +import { ILinkMatcherOptions } from './Interfaces'; import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback } from './Types'; const INVALID_LINK_CLASS = 'xterm-invalid-link'; @@ -60,7 +60,7 @@ export class Linkifier { * @param document The document object. * @param rows The array of rows to apply links to. */ - public attachToDom(document: Document, rows: HTMLElement[]) { + public attachToDom(document: Document, rows: HTMLElement[]): void { this._document = document; this._rows = rows; } @@ -108,10 +108,10 @@ export class Linkifier { * this searches the textContent of the rows. You will want to use \s to match * a space ' ' character for example. * @param {LinkHandler} handler The callback when the link is called. - * @param {LinkMatcherOptions} [options] Options for the link matcher. + * @param {ILinkMatcherOptions} [options] Options for the link matcher. * @return {number} The ID of the new matcher, this can be used to deregister. */ - public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number { + public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: ILinkMatcherOptions = {}): number { if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) { throw new Error('handler must be defined'); } diff --git a/src/Parser.ts b/src/Parser.ts index 1c2fb6b0..90d413a7 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -182,7 +182,12 @@ export class Parser { * @param data The data to parse. */ public parse(data: string): ParserState { - let l = data.length, j, cs, ch, code, low; + const l = data.length; + let j; + let cs; + let ch; + let code; + let low; if (this._terminal.debug) { this._terminal.log('data: ' + data); @@ -608,7 +613,7 @@ export class Parser { * * @param param the parameter. */ - public setParam(param: number) { + public setParam(param: number): void { this._terminal.currentParam = param; } diff --git a/src/Renderer.ts b/src/Renderer.ts index 165594fd..f706bffc 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -37,7 +37,7 @@ export class Renderer { // Figure out whether boldness affects // the character width of monospace fonts. if (brokenBold === null) { - brokenBold = checkBoldBroken((this._terminal).element); + brokenBold = checkBoldBroken(this._terminal.element); } this._spanElementObjectPool = new DomElementObjectPool('span'); @@ -327,7 +327,7 @@ export class Renderer { * @param start The selection start. * @param end The selection end. */ - public refreshSelection(start: [number, number], end: [number, number]) { + public refreshSelection(start: [number, number], end: [number, number]): void { // Remove all selections while (this._terminal.selectionContainer.children.length) { this._terminal.selectionContainer.removeChild(this._terminal.selectionContainer.children[0]); @@ -385,16 +385,16 @@ export class Renderer { // If bold is broken, we can't use it in the terminal. -function checkBoldBroken(terminal) { - const document = terminal.ownerDocument; +function checkBoldBroken(terminalElement: HTMLElement): boolean { + const document = terminalElement.ownerDocument; const el = document.createElement('span'); el.innerHTML = 'hello world'; - terminal.appendChild(el); + terminalElement.appendChild(el); const w1 = el.offsetWidth; const h1 = el.offsetHeight; el.style.fontWeight = 'bold'; const w2 = el.offsetWidth; const h2 = el.offsetHeight; - terminal.removeChild(el); + terminalElement.removeChild(el); return w1 !== w2 || h1 !== h2; } diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index c2173d77..698cda7e 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -1,6 +1,7 @@ /** * @license MIT */ + import jsdom = require('jsdom'); import { assert } from 'chai'; import { ITerminal, ICircularList } from './Interfaces'; @@ -9,11 +10,13 @@ import { CircularList } from './utils/CircularList'; import { SelectionManager } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; +import { MockTerminal } from './utils/TestUtils'; +import { LineData } from './Types'; class TestSelectionManager extends SelectionManager { constructor( terminal: ITerminal, - buffer: ICircularList<[number, string, number][]>, + buffer: ICircularList, rowContainer: HTMLElement, charMeasure: CharMeasure ) { @@ -37,7 +40,7 @@ describe('SelectionManager', () => { let document: Document; let terminal: ITerminal; - let bufferLines: ICircularList<[number, string, number][]>; + let bufferLines: ICircularList; let rowContainer: HTMLElement; let selectionManager: TestSelectionManager; @@ -46,16 +49,18 @@ describe('SelectionManager', () => { window = dom.window; document = window.document; rowContainer = document.createElement('div'); - terminal = { cols: 80, rows: 2 }; - terminal.scrollback = 100; + terminal = new MockTerminal(); + terminal.cols = 80; + terminal.rows = 2; + terminal.options.scrollback = 100; terminal.buffers = new BufferSet(terminal); terminal.buffer = terminal.buffers.active; bufferLines = terminal.buffer.lines; selectionManager = new TestSelectionManager(terminal, bufferLines, rowContainer, null); }); - function stringToRow(text: string): [number, string, number][] { - let result: [number, string, number][] = []; + function stringToRow(text: string): LineData { + let result: LineData = []; for (let i = 0; i < text.length; i++) { result.push([0, text.charAt(i), 1]); } @@ -64,7 +69,7 @@ describe('SelectionManager', () => { describe('_selectWordAt', () => { it('should expand selection for normal width chars', () => { - bufferLines.push(stringToRow('foo bar')); + bufferLines.set(0, stringToRow('foo bar')); selectionManager.selectWordAt([0, 0]); assert.equal(selectionManager.selectionText, 'foo'); selectionManager.selectWordAt([1, 0]); @@ -81,7 +86,7 @@ describe('SelectionManager', () => { assert.equal(selectionManager.selectionText, 'bar'); }); it('should expand selection for whitespace', () => { - bufferLines.push(stringToRow('a b')); + bufferLines.set(0, stringToRow('a b')); selectionManager.selectWordAt([0, 0]); assert.equal(selectionManager.selectionText, 'a'); selectionManager.selectWordAt([1, 0]); @@ -95,7 +100,7 @@ describe('SelectionManager', () => { }); it('should expand selection for wide characters', () => { // Wide characters use a special format - bufferLines.push([ + bufferLines.set(0, [ [null, '中', 2], [null, '', 0], [null, '文', 2], @@ -147,7 +152,7 @@ describe('SelectionManager', () => { assert.equal(selectionManager.selectionText, 'foo'); }); it('should select up to non-path characters that are commonly adjacent to paths', () => { - bufferLines.push(stringToRow('(cd)[ef]{gh}\'ij"')); + bufferLines.set(0, stringToRow('(cd)[ef]{gh}\'ij"')); selectionManager.selectWordAt([0, 0]); assert.equal(selectionManager.selectionText, '(cd'); selectionManager.selectWordAt([1, 0]); @@ -185,7 +190,7 @@ describe('SelectionManager', () => { describe('_selectLineAt', () => { it('should select the entire line', () => { - bufferLines.push(stringToRow('foo bar')); + bufferLines.set(0, stringToRow('foo bar')); selectionManager.selectLineAt(0); assert.equal(selectionManager.selectionText, 'foo bar', 'The selected text is correct'); assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); @@ -195,11 +200,12 @@ describe('SelectionManager', () => { describe('selectAll', () => { it('should select the entire buffer, beyond the viewport', () => { - bufferLines.push(stringToRow('1')); - bufferLines.push(stringToRow('2')); - bufferLines.push(stringToRow('3')); - bufferLines.push(stringToRow('4')); - bufferLines.push(stringToRow('5')); + bufferLines.length = 5; + bufferLines.set(0, stringToRow('1')); + bufferLines.set(1, stringToRow('2')); + bufferLines.set(2, stringToRow('3')); + bufferLines.set(3, stringToRow('4')); + bufferLines.set(4, stringToRow('5')); selectionManager.selectAll(); terminal.buffer.ybase = bufferLines.length - terminal.rows; assert.equal(selectionManager.selectionText, '1\n2\n3\n4\n5'); diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index f6fb44da..781a8eda 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -7,9 +7,10 @@ import * as Browser from './utils/Browser'; import { CharMeasure } from './utils/CharMeasure'; import { CircularList } from './utils/CircularList'; import { EventEmitter } from './EventEmitter'; -import { ITerminal, ICircularList } from './Interfaces'; +import { ITerminal, ICircularList, ISelectionManager } from './Interfaces'; import { SelectionModel } from './SelectionModel'; import { translateBufferLineToString } from './utils/BufferLine'; +import { LineData } from './Types'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -66,7 +67,7 @@ enum SelectionMode { * not handled by the SelectionManager but a 'refresh' event is fired when the * selection is ready to be redrawn. */ -export class SelectionManager extends EventEmitter { +export class SelectionManager extends EventEmitter implements ISelectionManager { protected _model: SelectionModel; /** @@ -101,7 +102,7 @@ export class SelectionManager extends EventEmitter { constructor( private _terminal: ITerminal, - private _buffer: ICircularList<[number, string, number][]>, + private _buffer: ICircularList, private _rowContainer: HTMLElement, private _charMeasure: CharMeasure ) { @@ -116,7 +117,7 @@ export class SelectionManager extends EventEmitter { /** * Initializes listener variables. */ - private _initListeners() { + private _initListeners(): void { this._mouseMoveListener = event => this._onMouseMove(event); this._mouseUpListener = event => this._onMouseUp(event); @@ -133,7 +134,7 @@ export class SelectionManager extends EventEmitter { * Disables the selection manager. This is useful for when terminal mouse * are enabled. */ - public disable() { + public disable(): void { this.clearSelection(); this._enabled = false; } @@ -141,7 +142,7 @@ export class SelectionManager extends EventEmitter { /** * Enable the selection manager. */ - public enable() { + public enable(): void { this._enabled = true; } @@ -150,7 +151,7 @@ export class SelectionManager extends EventEmitter { * switched in or out. * @param buffer The active buffer. */ - public setBuffer(buffer: ICircularList<[number, string, number][]>): void { + public setBuffer(buffer: ICircularList): void { this._buffer = buffer; this.clearSelection(); } @@ -267,7 +268,7 @@ export class SelectionManager extends EventEmitter { * Handle the buffer being trimmed, adjust the selection position. * @param amount The amount the buffer is being trimmed. */ - private _onTrim(amount: number) { + private _onTrim(amount: number): void { const needsRefresh = this._model.onTrim(amount); if (needsRefresh) { this.refresh(); @@ -316,7 +317,7 @@ export class SelectionManager extends EventEmitter { * Handles te mousedown event, setting up for a new selection. * @param event The mousedown event. */ - private _onMouseDown(event: MouseEvent) { + private _onMouseDown(event: MouseEvent): void { // If we have selection, we want the context menu on right click even if the // terminal is in mouse mode. if (event.button === 2 && this.hasSelection) { @@ -455,7 +456,7 @@ export class SelectionManager extends EventEmitter { * end of the selection and refreshing the selection. * @param event The mousemove event. */ - private _onMouseMove(event: MouseEvent) { + private _onMouseMove(event: MouseEvent): void { // Record the previous position so we know whether to redraw the selection // at the end. const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null; @@ -511,7 +512,7 @@ export class SelectionManager extends EventEmitter { * The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the * scrolling of the viewport. */ - private _dragScroll() { + private _dragScroll(): void { if (this._dragScrollAmount) { this._terminal.scrollDisp(this._dragScrollAmount, false); // Re-evaluate selection @@ -528,7 +529,7 @@ export class SelectionManager extends EventEmitter { * Handles the mouseup event, removing the mousedown listeners. * @param event The mouseup event. */ - private _onMouseUp(event: MouseEvent) { + private _onMouseUp(event: MouseEvent): void { this._removeMouseDownListeners(); } diff --git a/src/SelectionModel.test.ts b/src/SelectionModel.test.ts index 6da3874b..9da84478 100644 --- a/src/SelectionModel.test.ts +++ b/src/SelectionModel.test.ts @@ -1,10 +1,12 @@ /** * @license MIT */ + import { assert } from 'chai'; import { ITerminal } from './Interfaces'; import { SelectionModel } from './SelectionModel'; import {BufferSet} from './BufferSet'; +import { MockTerminal } from './utils/TestUtils'; class TestSelectionModel extends SelectionModel { constructor( @@ -22,8 +24,10 @@ describe('SelectionManager', () => { let model: TestSelectionModel; beforeEach(() => { - terminal = { cols: 80, rows: 2, ybase: 0 }; - terminal.scrollback = 10; + terminal = new MockTerminal(); + terminal.cols = 80; + terminal.rows = 2; + terminal.options.scrollback = 10; terminal.buffers = new BufferSet(terminal); terminal.buffer = terminal.buffers.active; diff --git a/src/Terminal.ts b/src/Terminal.ts new file mode 100644 index 00000000..cd44e7fb --- /dev/null +++ b/src/Terminal.ts @@ -0,0 +1,2345 @@ +/** + * xterm.js: xterm, in the browser + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + * @license MIT + */ + +import { BufferSet } from './BufferSet'; +import { Buffer } from './Buffer'; +import { CompositionHelper } from './CompositionHelper'; +import { EventEmitter } from './EventEmitter'; +import { Viewport } from './Viewport'; +import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './handlers/Clipboard'; +import { CircularList } from './utils/CircularList'; +import { C0 } from './EscapeSequences'; +import { InputHandler } from './InputHandler'; +import { Parser } from './Parser'; +import { Renderer } from './Renderer'; +import { Linkifier } from './Linkifier'; +import { SelectionManager } from './SelectionManager'; +import { CharMeasure } from './utils/CharMeasure'; +import * as Browser from './utils/Browser'; +import * as Mouse from './utils/Mouse'; +import { CHARSETS } from './Charsets'; +import { getRawByteCoords } from './utils/Mouse'; +import { translateBufferLineToString } from './utils/BufferLine'; +import { CustomKeyEventHandler, Charset, LinkMatcherHandler, LinkMatcherValidationCallback, CharData, LineData, Option, StringOption, BooleanOption, StringArrayOption, NumberOption, GeometryOption, HandlerOption } from './Types'; +import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions } from './Interfaces'; + +// Declare for RequireJS in loadAddon +declare var define: any; + +// Let it work inside Node.js for automated testing purposes. +const document = (typeof window !== 'undefined') ? window.document : null; + +/** + * The amount of write requests to queue before sending an XOFF signal to the + * pty process. This number must be small in order for ^C and similar sequences + * to be responsive. + */ +const WRITE_BUFFER_PAUSE_THRESHOLD = 5; + +/** + * The number of writes to perform in a single batch before allowing the + * renderer to catch up with a 0ms setTimeout. + */ +const WRITE_BATCH_SIZE = 300; + +/** + * The time between cursor blinks. This is driven by JS rather than a CSS + * animation due to a bug in Chromium that causes it to use excessive CPU time. + * See https://github.com/Microsoft/vscode/issues/22900 + */ +const CURSOR_BLINK_INTERVAL = 600; + +// TODO: Most of the color code should be removed after truecolor is implemented +// Colors 0-15 +const tangoColors: string[] = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +]; + +// Colors 0-15 + 16-255 +// Much thanks to TooTallNate for writing this. +const defaultColors: string[] = (function(): string[] { + let colors = tangoColors.slice(); + let r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; + let i; + + // 16-231 + i = 0; + for (; i < 216; i++) { + out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); + } + + // 232-255 (grey) + i = 0; + let c: number; + for (; i < 24; i++) { + c = 8 + i * 10; + out(c, c, c); + } + + function out(r: number, g: number, b: number): void { + colors.push('#' + hex(r) + hex(g) + hex(b)); + } + + function hex(c: number): string { + let s = c.toString(16); + return s.length < 2 ? '0' + s : s; + } + + return colors; +})(); + +const _colors: string[] = defaultColors.slice(); + +const vcolors: number[][] = (function(): number[][] { + const out: number[][] = []; + let color; + + for (let i = 0; i < 256; i++) { + color = parseInt(defaultColors[i].substring(1), 16); + out.push([ + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff + ]); + } + + return out; +})(); + +const DEFAULT_OPTIONS: ITerminalOptions = { + colors: defaultColors, + convertEol: false, + termName: 'xterm', + geometry: [80, 24], + cursorBlink: false, + cursorStyle: 'block', + visualBell: false, + popOnBell: false, + scrollback: 1000, + screenKeys: false, + debug: false, + cancelEvents: false, + disableStdin: false, + useFlowControl: false, + tabStopWidth: 8 + // programFeatures: false, + // focusKeys: false, +}; + +export class Terminal extends EventEmitter implements ITerminal, IInputHandlingTerminal { + public textarea: HTMLTextAreaElement; + public element: HTMLElement; + public rowContainer: HTMLElement; + + /** + * The HTMLElement that the terminal is created in, set by Terminal.open. + */ + private parent: HTMLElement; + private context: Window; + private document: Document; + private body: HTMLBodyElement; + private viewportScrollArea: HTMLElement; + private viewportElement: HTMLElement; + public selectionContainer: HTMLElement; + private helperContainer: HTMLElement; + private compositionView: HTMLElement; + private charSizeStyleElement: HTMLStyleElement; + + public browser: IBrowser = Browser; + + public options: ITerminalOptions; + private colors: any; + + // TODO: This can be changed to an enum or boolean, 0 and 1 seem to be the only options + public cursorState: number; + public cursorHidden: boolean; + public convertEol: boolean; + + private sendDataQueue: string; + private customKeyEventHandler: CustomKeyEventHandler; + // The ID from a setInterval that tracks the blink animation. This animation + // is done in JS due to a Chromium bug with CSS animations that thrashed the + // CPU. + private cursorBlinkInterval: NodeJS.Timer; + + // modes + public applicationKeypad: boolean; + public applicationCursor: boolean; + public originMode: boolean; + public insertMode: boolean; + public wraparoundMode: boolean; // defaults: xterm - true, vt100 - false + + // charset + // The current charset + public charset: Charset; + public gcharset: number; + public glevel: number; + public charsets: Charset[]; + + // mouse properties + private decLocator: boolean; // This is unstable and never set + public x10Mouse: boolean; + public vt200Mouse: boolean; + private vt300Mouse: boolean; // This is unstable and never set + public normalMouse: boolean; + public mouseEvents: boolean; + public sendFocus: boolean; + public utfMouse: boolean; + public sgrMouse: boolean; + public urxvtMouse: boolean; + + // misc + public children: HTMLElement[]; + private refreshStart: number; + private refreshEnd: number; + public savedCols: number; + + // stream + private readable: boolean; + private writable: boolean; + + public defAttr: number; + public curAttr: number; + + public params: (string | number)[]; + public currentParam: string | number; + public prefix: string; + public postfix: string; + + // user input states + public writeBuffer: string[]; + private writeInProgress: boolean; + + /** + * Whether _xterm.js_ sent XOFF in order to catch up with the pty process. + * This is a distinct state from writeStopped so that if the user requested + * XOFF via ^S that it will not automatically resume when the writeBuffer goes + * below threshold. + */ + private xoffSentToCatchUp: boolean; + + /** Whether writing has been stopped as a result of XOFF */ + private writeStopped: boolean; + + // leftover surrogate high from previous write invocation + private surrogate_high: string; + + // Store if user went browsing history in scrollback + private userScrolling: boolean; + + private inputHandler: InputHandler; + private parser: Parser; + private renderer: Renderer; + public selectionManager: SelectionManager; + private linkifier: Linkifier; + public buffers: BufferSet; + public buffer: Buffer; + public viewport: Viewport; + private compositionHelper: CompositionHelper; + public charMeasure: CharMeasure; + + public cols: number; + public rows: number; + public geometry: [/*cols*/number, /*rows*/number]; + + /** + * Creates a new `Terminal` object. + * + * @param {object} options An object containing a set of options, the available options are: + * - `cursorBlink` (boolean): Whether the terminal cursor blinks + * - `cols` (number): The number of columns of the terminal (horizontal size) + * - `rows` (number): The number of rows of the terminal (vertical size) + * + * @public + * @class Xterm Xterm + * @alias module:xterm/src/xterm + */ + constructor( + options: ITerminalOptions = {} + ) { + super(); + this.options = options; + this.setup(); + } + + private setup(): void { + Object.keys(DEFAULT_OPTIONS).forEach((key) => { + if (this.options[key] == null) { + this.options[key] = DEFAULT_OPTIONS[key]; + } + // TODO: We should move away from duplicate options on the Terminal object + this[key] = this.options[key]; + }); + + if (this.options.colors.length === 8) { + this.options.colors = this.options.colors.concat(_colors.slice(8)); + } else if (this.options.colors.length === 16) { + this.options.colors = this.options.colors.concat(_colors.slice(16)); + } else if (this.options.colors.length === 10) { + this.options.colors = this.options.colors.slice(0, -2).concat( + _colors.slice(8, -2), this.options.colors.slice(-2)); + } else if (this.options.colors.length === 18) { + this.options.colors = this.options.colors.concat( + _colors.slice(16, -2), this.options.colors.slice(-2)); + } + this.colors = this.options.colors; + + // this.context = options.context || window; + // this.document = options.document || document; + // TODO: WHy not document.body? + this.parent = document ? document.body : null; + + this.cols = this.options.cols || this.options.geometry[0]; + this.rows = this.options.rows || this.options.geometry[1]; + this.geometry = [this.cols, this.rows]; + + if (this.options.handler) { + this.on('data', this.options.handler); + } + + this.cursorState = 0; + this.cursorHidden = false; + this.sendDataQueue = ''; + this.customKeyEventHandler = null; + this.cursorBlinkInterval = null; + + // modes + this.applicationKeypad = false; + this.applicationCursor = false; + this.originMode = false; + this.insertMode = false; + this.wraparoundMode = true; // defaults: xterm - true, vt100 - false + + // charset + this.charset = null; + this.gcharset = null; + this.glevel = 0; + // TODO: Can this be just []? + this.charsets = [null]; + + this.readable = true; + this.writable = true; + + this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = (0 << 18) | (257 << 9) | (256 << 0); + + this.params = []; + this.currentParam = 0; + this.prefix = ''; + this.postfix = ''; + + // user input states + this.writeBuffer = []; + this.writeInProgress = false; + + this.xoffSentToCatchUp = false; + this.writeStopped = false; + this.surrogate_high = ''; + this.userScrolling = false; + + this.inputHandler = new InputHandler(this); + this.parser = new Parser(this.inputHandler, this); + // Reuse renderer if the Terminal is being recreated via a reset call. + this.renderer = this.renderer || null; + this.selectionManager = this.selectionManager || null; + this.linkifier = this.linkifier || new Linkifier(); + + // Create the terminal's buffers and set the current buffer + this.buffers = new BufferSet(this); + this.buffer = this.buffers.active; // Convenience shortcut; + this.buffers.on('activate', (buffer: Buffer) => { + this.buffer = buffer; + }); + + // Ensure the selection manager has the correct buffer + if (this.selectionManager) { + this.selectionManager.setBuffer(this.buffer.lines); + } + + this.setupStops(); + } + + /** + * back_color_erase feature for xterm. + */ + public eraseAttr(): number { + // if (this.is('screen')) return this.defAttr; + return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); + } + + /** + * Focus the terminal. Delegates focus handling to the terminal's DOM element. + */ + public focus(): void { + this.textarea.focus(); + } + + /** + * Retrieves an option's value from the terminal. + * @param {string} key The option key. + */ + public getOption(key: StringOption): string; + public getOption(key: BooleanOption): boolean; + public getOption(key: StringArrayOption): number[]; + public getOption(key: NumberOption): number; + public getOption(key: GeometryOption): [number, number]; + public getOption(key: HandlerOption): (data: string) => void; + public getOption(key: Option): any { + if (!(key in DEFAULT_OPTIONS)) { + throw new Error('No option with key "' + key + '"'); + } + + if (typeof this.options[key] !== 'undefined') { + return this.options[key]; + } + + return this[key]; + } + + /** + * Sets an option on the terminal. + * @param {string} key The option key. + * @param {any} value The option value. + */ + public setOption(key: StringOption, value: string): void; + public setOption(key: BooleanOption, value: boolean): void; + public setOption(key: StringArrayOption, value: number[]): void; + public setOption(key: NumberOption, value: number): void; + public setOption(key: GeometryOption, value: [number, number]): void; + public setOption(key: HandlerOption, value: (data: string) => void): void; + public setOption(key: Option, value: any): void { + if (!(key in DEFAULT_OPTIONS)) { + throw new Error('No option with key "' + key + '"'); + } + switch (key) { + case 'scrollback': + if (value < this.rows) { + let msg = 'Setting the scrollback value less than the number of rows '; + + msg += `(${this.rows}) is not allowed.`; + + console.warn(msg); + return; + } + + if (this.options[key] !== value) { + if (this.buffer.lines.length > value) { + const amountToTrim = this.buffer.lines.length - value; + const needsRefresh = (this.buffer.ydisp - amountToTrim < 0); + this.buffer.lines.trimStart(amountToTrim); + this.buffer.ybase = Math.max(this.buffer.ybase - amountToTrim, 0); + this.buffer.ydisp = Math.max(this.buffer.ydisp - amountToTrim, 0); + if (needsRefresh) { + this.refresh(0, this.rows - 1); + } + } + this.buffer.lines.maxLength = value; + this.viewport.syncScrollArea(); + } + break; + } + this[key] = value; + this.options[key] = value; + switch (key) { + case 'cursorBlink': this.setCursorBlinking(value); break; + case 'cursorStyle': + this.element.classList.toggle(`xterm-cursor-style-block`, value === 'block'); + this.element.classList.toggle(`xterm-cursor-style-underline`, value === 'underline'); + this.element.classList.toggle(`xterm-cursor-style-bar`, value === 'bar'); + break; + case 'tabStopWidth': this.setupStops(); break; + } + } + + private restartCursorBlinking(): void { + this.setCursorBlinking(this.options.cursorBlink); + } + + private setCursorBlinking(enabled: boolean): void { + this.element.classList.toggle('xterm-cursor-blink', enabled); + this.clearCursorBlinkingInterval(); + if (enabled) { + this.cursorBlinkInterval = setInterval(() => { + this.element.classList.toggle('xterm-cursor-blink-on'); + }, CURSOR_BLINK_INTERVAL); + } + } + + private clearCursorBlinkingInterval(): void { + this.element.classList.remove('xterm-cursor-blink-on'); + if (this.cursorBlinkInterval) { + clearInterval(this.cursorBlinkInterval); + this.cursorBlinkInterval = null; + } + } + + /** + * Binds the desired focus behavior on a given terminal object. + */ + private bindFocus(): void { + globalOn(this.textarea, 'focus', (ev) => { + if (this.sendFocus) { + this.send(C0.ESC + '[I'); + } + this.element.classList.add('focus'); + this.showCursor(); + this.restartCursorBlinking.apply(this); + // TODO: Why pass terminal here? + this.emit('focus', {terminal: this}); + }); + }; + + /** + * Blur the terminal. Delegates blur handling to the terminal's DOM element. + */ + private blur(): void { + return this.textarea.blur(); + } + + /** + * Binds the desired blur behavior on a given terminal object. + */ + private bindBlur(): void { + on(this.textarea, 'blur', (ev) => { + this.refresh(this.buffer.y, this.buffer.y); + if (this.sendFocus) { + this.send(C0.ESC + '[O'); + } + this.element.classList.remove('focus'); + this.clearCursorBlinkingInterval.apply(this); + // TODO: Why pass terminal here? + this.emit('blur', {terminal: this}); + }); + } + + /** + * Initialize default behavior + */ + private initGlobal(): void { + this.bindKeys(); + this.bindFocus(); + this.bindBlur(); + + // Bind clipboard functionality + on(this.element, 'copy', (event: ClipboardEvent) => { + // If mouse events are active it means the selection manager is disabled and + // copy should be handled by the host program. + if (!this.hasSelection()) { + return; + } + copyHandler(event, this, this.selectionManager); + }); + const pasteHandlerWrapper = event => pasteHandler(event, this); + on(this.textarea, 'paste', pasteHandlerWrapper); + on(this.element, 'paste', pasteHandlerWrapper); + + // Handle right click context menus + if (Browser.isFirefox) { + // Firefox doesn't appear to fire the contextmenu event on right click + on(this.element, 'mousedown', (event: MouseEvent) => { + if (event.button === 2) { + rightClickHandler(event, this.textarea, this.selectionManager); + } + }); + } else { + on(this.element, 'contextmenu', (event: MouseEvent) => { + rightClickHandler(event, this.textarea, this.selectionManager); + }); + } + + // Move the textarea under the cursor when middle clicking on Linux to ensure + // middle click to paste selection works. This only appears to work in Chrome + // at the time is writing. + if (Browser.isLinux) { + // Use auxclick event over mousedown the latter doesn't seem to work. Note + // that the regular click event doesn't fire for the middle mouse button. + on(this.element, 'auxclick', (event: MouseEvent) => { + if (event.button === 1) { + moveTextAreaUnderMouseCursor(event, this.textarea); + } + }); + } + } + + /** + * Apply key handling to the terminal + */ + private bindKeys(): void { + const self = this; + on(this.element, 'keydown', function (ev: KeyboardEvent): void { + if (document.activeElement !== this) { + return; + } + self.keyDown(ev); + }, true); + + on(this.element, 'keypress', function (ev: KeyboardEvent): void { + if (document.activeElement !== this) { + return; + } + self.keyPress(ev); + }, true); + + on(this.element, 'keyup', (ev: KeyboardEvent) => { + if (!wasMondifierKeyOnlyEvent(ev)) { + this.focus(); + } + }, true); + + on(this.textarea, 'keydown', (ev: KeyboardEvent) => { + this.keyDown(ev); + }, true); + + on(this.textarea, 'keypress', (ev: KeyboardEvent) => { + this.keyPress(ev); + // Truncate the textarea's value, since it is not needed + this.textarea.value = ''; + }, true); + + on(this.textarea, 'compositionstart', this.compositionHelper.compositionstart.bind(this.compositionHelper)); + on(this.textarea, 'compositionupdate', this.compositionHelper.compositionupdate.bind(this.compositionHelper)); + on(this.textarea, 'compositionend', this.compositionHelper.compositionend.bind(this.compositionHelper)); + this.on('refresh', this.compositionHelper.updateCompositionElements.bind(this.compositionHelper)); + this.on('refresh', (data) => this.queueLinkification(data.start, data.end)); + } + + /** + * Insert the given row to the terminal or produce a new one + * if no row argument is passed. Return the inserted row. + * @param {HTMLElement} row (optional) The row to append to the terminal. + */ + private insertRow(row?: HTMLElement): HTMLElement { + if (typeof row !== 'object') { + row = document.createElement('div'); + } + + this.rowContainer.appendChild(row); + this.children.push(row); + + return row; + }; + + /** + * Opens the terminal within an element. + * + * @param {HTMLElement} parent The element to create the terminal within. + * @param {boolean} focus Focus the terminal, after it gets instantiated in the DOM + */ + private open(parent: HTMLElement, focus?: boolean): void { + let i = 0; + let div; + + this.parent = parent || this.parent; + + if (!this.parent) { + throw new Error('Terminal requires a parent element.'); + } + + // Grab global elements + this.context = this.parent.ownerDocument.defaultView; + this.document = this.parent.ownerDocument; + this.body = this.document.body; + + // Create main element container + this.element = this.document.createElement('div'); + this.element.classList.add('terminal'); + this.element.classList.add('xterm'); + this.element.classList.add(`xterm-cursor-style-${this.options.cursorStyle}`); + this.setCursorBlinking(this.options.cursorBlink); + + this.element.setAttribute('tabindex', '0'); + + this.viewportElement = document.createElement('div'); + this.viewportElement.classList.add('xterm-viewport'); + this.element.appendChild(this.viewportElement); + this.viewportScrollArea = document.createElement('div'); + this.viewportScrollArea.classList.add('xterm-scroll-area'); + this.viewportElement.appendChild(this.viewportScrollArea); + + // Create the selection container. + this.selectionContainer = document.createElement('div'); + this.selectionContainer.classList.add('xterm-selection'); + this.element.appendChild(this.selectionContainer); + + // Create the container that will hold the lines of the terminal and then + // produce the lines the lines. + this.rowContainer = document.createElement('div'); + this.rowContainer.classList.add('xterm-rows'); + this.element.appendChild(this.rowContainer); + this.children = []; + this.linkifier.attachToDom(document, this.children); + + // Create the container that will hold helpers like the textarea for + // capturing DOM Events. Then produce the helpers. + this.helperContainer = document.createElement('div'); + this.helperContainer.classList.add('xterm-helpers'); + // TODO: This should probably be inserted once it's filled to prevent an additional layout + this.element.appendChild(this.helperContainer); + this.textarea = document.createElement('textarea'); + this.textarea.classList.add('xterm-helper-textarea'); + this.textarea.setAttribute('autocorrect', 'off'); + this.textarea.setAttribute('autocapitalize', 'off'); + this.textarea.setAttribute('spellcheck', 'false'); + this.textarea.tabIndex = 0; + this.textarea.addEventListener('focus', () => { + // TODO: Do we need terminal passed here? + this.emit('focus', {terminal: this}); + }); + this.textarea.addEventListener('blur', () => { + // TODO: Do we need terminal passed here? + this.emit('blur', {terminal: this}); + }); + this.helperContainer.appendChild(this.textarea); + + this.compositionView = document.createElement('div'); + this.compositionView.classList.add('composition-view'); + this.compositionHelper = new CompositionHelper(this.textarea, this.compositionView, this); + this.helperContainer.appendChild(this.compositionView); + + this.charSizeStyleElement = document.createElement('style'); + this.helperContainer.appendChild(this.charSizeStyleElement); + + for (; i < this.rows; i++) { + this.insertRow(); + } + this.parent.appendChild(this.element); + + this.charMeasure = new CharMeasure(document, this.helperContainer); + this.charMeasure.on('charsizechanged', () => { + this.updateCharSizeStyles(); + }); + this.charMeasure.measure(); + + this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); + this.renderer = new Renderer(this); + this.selectionManager = new SelectionManager(this, this.buffer.lines, this.rowContainer, this.charMeasure); + this.selectionManager.on('refresh', data => { + this.renderer.refreshSelection(data.start, data.end); + }); + this.selectionManager.on('newselection', text => { + // If there's a new selection, put it into the textarea, focus and select it + // in order to register it as a selection on the OS. This event is fired + // only on Linux to enable middle click to paste selection. + this.textarea.value = text; + this.textarea.focus(); + this.textarea.select(); + }); + this.on('scroll', () => this.selectionManager.refresh()); + this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); + + // Setup loop that draws to screen + this.refresh(0, this.rows - 1); + + // Initialize global actions that need to be taken on the document. + this.initGlobal(); + + /** + * Automatic focus functionality. + * TODO: Default to `false` starting with xterm.js 3.0. + */ + if (typeof focus === 'undefined') { + let message = 'You did not pass the `focus` argument in `Terminal.prototype.open()`.\n'; + + message += 'The `focus` argument now defaults to `true` but starting with xterm.js 3.0 '; + message += 'it will default to `false`.'; + + console.warn(message); + focus = true; + } + + if (focus) { + this.focus(); + } + + // Listen for mouse events and translate + // them into terminal mouse protocols. + this.bindMouse(); + + /** + * This event is emitted when terminal has completed opening. + * + * @event open + */ + this.emit('open'); + } + + /** + * Attempts to load an add-on using CommonJS or RequireJS (whichever is available). + * @param {string} addon The name of the addon to load + * @static + */ + public static loadAddon(addon: string, callback?: Function): boolean | any { + // TODO: Improve return type and documentation + if (typeof exports === 'object' && typeof module === 'object') { + // CommonJS + return require('./addons/' + addon + '/' + addon); + } else if (typeof define === 'function') { + // RequireJS + return (require)(['./addons/' + addon + '/' + addon], callback); + } else { + console.error('Cannot load a module without a CommonJS or RequireJS environment.'); + return false; + } + } + + /** + * Updates the helper CSS class with any changes necessary after the terminal's + * character width has been changed. + */ + public updateCharSizeStyles(): void { + this.charSizeStyleElement.textContent = + `.xterm-wide-char{width:${this.charMeasure.width * 2}px;}` + + `.xterm-normal-char{width:${this.charMeasure.width}px;}` + + `.xterm-rows > div{height:${this.charMeasure.height}px;}`; + } + + /** + * XTerm mouse events + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking + * To better understand these + * the xterm code is very helpful: + * Relevant files: + * button.c, charproc.c, misc.c + * Relevant functions in xterm/button.c: + * BtnCode, EmitButtonCode, EditorButton, SendMousePosition + */ + public bindMouse(): void { + const el = this.element; + const self = this; + let pressed = 32; + + // mouseup, mousedown, wheel + // left click: ^[[M 3<^[[M#3< + // wheel up: ^[[M`3> + function sendButton(ev: MouseEvent | WheelEvent): void { + let button; + let pos; + + // get the xterm-style button + button = getButton(ev); + + // get mouse coordinates + pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); + if (!pos) return; + + sendEvent(button, pos); + + switch ((ev).overrideType || ev.type) { + case 'mousedown': + pressed = button; + break; + case 'mouseup': + // keep it at the left + // button, just in case. + pressed = 32; + break; + case 'wheel': + // nothing. don't + // interfere with + // `pressed`. + break; + } + } + + // motion example of a left click: + // ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7< + function sendMove(ev: MouseEvent): void { + let button = pressed; + let pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); + if (!pos) return; + + // buttons marked as motions + // are incremented by 32 + button += 32; + + sendEvent(button, pos); + } + + // encode button and + // position to characters + function encode(data: number[], ch: number): void { + if (!self.utfMouse) { + if (ch === 255) { + data.push(0); + return; + } + if (ch > 127) ch = 127; + data.push(ch); + } else { + if (ch === 2047) { + data.push(0); + return; + } + if (ch < 127) { + data.push(ch); + } else { + if (ch > 2047) ch = 2047; + data.push(0xC0 | (ch >> 6)); + data.push(0x80 | (ch & 0x3F)); + } + } + } + + // send a mouse event: + // regular/utf8: ^[[M Cb Cx Cy + // urxvt: ^[[ Cb ; Cx ; Cy M + // sgr: ^[[ Cb ; Cx ; Cy M/m + // vt300: ^[[ 24(1/3/5)~ [ Cx , Cy ] \r + // locator: CSI P e ; P b ; P r ; P c ; P p & w + function sendEvent(button: number, pos: {x: number, y: number}): void { + // self.emit('mouse', { + // x: pos.x - 32, + // y: pos.x - 32, + // button: button + // }); + + if (self.vt300Mouse) { + // NOTE: Unstable. + // http://www.vt100.net/docs/vt3xx-gp/chapter15.html + button &= 3; + pos.x -= 32; + pos.y -= 32; + let data = C0.ESC + '[24'; + if (button === 0) data += '1'; + else if (button === 1) data += '3'; + else if (button === 2) data += '5'; + else if (button === 3) return; + else data += '0'; + data += '~[' + pos.x + ',' + pos.y + ']\r'; + self.send(data); + return; + } + + if (self.decLocator) { + // NOTE: Unstable. + button &= 3; + pos.x -= 32; + pos.y -= 32; + if (button === 0) button = 2; + else if (button === 1) button = 4; + else if (button === 2) button = 6; + else if (button === 3) button = 3; + self.send(C0.ESC + '[' + + button + + ';' + + (button === 3 ? 4 : 0) + + ';' + + pos.y + + ';' + + pos.x + + ';' + // Not sure what page is meant to be + + (pos).page || 0 + + '&w'); + return; + } + + if (self.urxvtMouse) { + pos.x -= 32; + pos.y -= 32; + pos.x++; + pos.y++; + self.send(C0.ESC + '[' + button + ';' + pos.x + ';' + pos.y + 'M'); + return; + } + + if (self.sgrMouse) { + pos.x -= 32; + pos.y -= 32; + self.send(C0.ESC + '[<' + + (((button & 3) === 3 ? button & ~3 : button) - 32) + + ';' + + pos.x + + ';' + + pos.y + + ((button & 3) === 3 ? 'm' : 'M')); + return; + } + + let data: number[] = []; + + encode(data, button); + encode(data, pos.x); + encode(data, pos.y); + + self.send(C0.ESC + '[M' + String.fromCharCode.apply(String, data)); + } + + function getButton(ev: MouseEvent): number { + let button; + let shift; + let meta; + let ctrl; + let mod; + + // two low bits: + // 0 = left + // 1 = middle + // 2 = right + // 3 = release + // wheel up/down: + // 1, and 2 - with 64 added + switch ((ev).overrideType || ev.type) { + case 'mousedown': + button = ev.button != null + ? +ev.button + : ev.which != null + ? ev.which - 1 + : null; + + if (Browser.isMSIE) { + button = button === 1 ? 0 : button === 4 ? 1 : button; + } + break; + case 'mouseup': + button = 3; + break; + case 'DOMMouseScroll': + button = ev.detail < 0 + ? 64 + : 65; + break; + case 'wheel': + button = (ev).wheelDeltaY > 0 + ? 64 + : 65; + break; + } + + // next three bits are the modifiers: + // 4 = shift, 8 = meta, 16 = control + shift = ev.shiftKey ? 4 : 0; + meta = ev.metaKey ? 8 : 0; + ctrl = ev.ctrlKey ? 16 : 0; + mod = shift | meta | ctrl; + + // no mods + if (self.vt200Mouse) { + // ctrl only + mod &= ctrl; + } else if (!self.normalMouse) { + mod = 0; + } + + // increment to SP + button = (32 + (mod << 2)) + button; + + return button; + } + + on(el, 'mousedown', (ev: MouseEvent) => { + + // Prevent the focus on the textarea from getting lost + // and make sure we get focused on mousedown + ev.preventDefault(); + this.focus(); + + if (!this.mouseEvents) return; + + // send the button + sendButton(ev); + + // fix for odd bug + // if (this.vt200Mouse && !this.normalMouse) { + if (this.vt200Mouse) { + (ev).overrideType = 'mouseup'; + sendButton(ev); + return this.cancel(ev); + } + + // bind events + if (this.normalMouse) on(this.document, 'mousemove', sendMove); + + // x10 compatibility mode can't send button releases + if (!this.x10Mouse) { + const handler = (ev: MouseEvent) => { + sendButton(ev); + // TODO: Seems dangerous calling this on document? + if (this.normalMouse) off(this.document, 'mousemove', sendMove); + off(this.document, 'mouseup', handler); + return this.cancel(ev); + }; + // TODO: Seems dangerous calling this on document? + on(this.document, 'mouseup', handler); + } + + return this.cancel(ev); + }); + + // if (this.normalMouse) { + // on(this.document, 'mousemove', sendMove); + // } + + on(el, 'wheel', (ev: WheelEvent) => { + if (!this.mouseEvents) return; + if (this.x10Mouse || this.vt300Mouse || this.decLocator) return; + sendButton(ev); + return this.cancel(ev); + }); + + // allow wheel scrolling in + // the shell for example + on(el, 'wheel', (ev: WheelEvent) => { + if (this.mouseEvents) return; + this.viewport.onWheel(ev); + return this.cancel(ev); + }); + + on(el, 'touchstart', (ev: TouchEvent) => { + if (this.mouseEvents) return; + this.viewport.onTouchStart(ev); + return this.cancel(ev); + }); + + on(el, 'touchmove', (ev: TouchEvent) => { + if (this.mouseEvents) return; + this.viewport.onTouchMove(ev); + return this.cancel(ev); + }); + } + + /** + * Destroys the terminal. + */ + public destroy(): void { + super.destroy(); + this.readable = false; + this.writable = false; + this.handler = () => {}; + this.write = () => {}; + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + // this.emit('close'); + } + + /** + * Tells the renderer to refresh terminal content between two rows (inclusive) at the next + * opportunity. + * @param {number} start The row to start from (between 0 and this.rows - 1). + * @param {number} end The row to end at (between start and this.rows - 1). + */ + public refresh(start: number, end: number): void { + if (this.renderer) { + this.renderer.queueRefresh(start, end); + } + } + + /** + * Queues linkification for the specified rows. + * @param {number} start The row to start from (between 0 and this.rows - 1). + * @param {number} end The row to end at (between start and this.rows - 1). + */ + private queueLinkification(start: number, end: number): void { + if (this.linkifier) { + for (let i = start; i <= end; i++) { + this.linkifier.linkifyRow(i); + } + } + } + + /** + * Display the cursor element + */ + public showCursor(): void { + if (!this.cursorState) { + this.cursorState = 1; + this.refresh(this.buffer.y, this.buffer.y); + } + } + + /** + * Scroll the terminal down 1 row, creating a blank line. + * @param {boolean} isWrapped Whether the new line is wrapped from the previous + * line. + */ + public scroll(isWrapped?: boolean): void { + let row; + + // Make room for the new row in lines + if (this.buffer.lines.length === this.buffer.lines.maxLength) { + this.buffer.lines.trimStart(1); + this.buffer.ybase--; + if (this.buffer.ydisp !== 0) { + this.buffer.ydisp--; + } + } + + this.buffer.ybase++; + + // TODO: Why is this done twice? + if (!this.userScrolling) { + this.buffer.ydisp = this.buffer.ybase; + } + + // last line + row = this.buffer.ybase + this.rows - 1; + + // subtract the bottom scroll region + row -= this.rows - 1 - this.buffer.scrollBottom; + + if (row === this.buffer.lines.length) { + // Optimization: pushing is faster than splicing when they amount to the same behavior + this.buffer.lines.push(this.blankLine(undefined, isWrapped)); + } else { + // add our new line + this.buffer.lines.splice(row, 0, this.blankLine(undefined, isWrapped)); + } + + if (this.buffer.scrollTop !== 0) { + if (this.buffer.ybase !== 0) { + this.buffer.ybase--; + if (!this.userScrolling) { + this.buffer.ydisp = this.buffer.ybase; + } + } + this.buffer.lines.splice(this.buffer.ybase + this.buffer.scrollTop, 1); + } + + // this.maxRange(); + 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); + } + + /** + * Scroll the display of the terminal + * @param {number} disp The number of lines to scroll down (negatives scroll up). + * @param {boolean} suppressScrollEvent Don't emit the scroll event as scrollDisp. This is used + * to avoid unwanted events being handled by the veiwport when the event was triggered from the + * viewport originally. + */ + public scrollDisp(disp: number, suppressScrollEvent?: boolean): void { + if (disp < 0) { + if (this.buffer.ydisp === 0) { + return; + } + this.userScrolling = true; + } else if (disp + this.buffer.ydisp >= this.buffer.ybase) { + this.userScrolling = false; + } + + const oldYdisp = this.buffer.ydisp; + this.buffer.ydisp = Math.max(Math.min(this.buffer.ydisp + disp, this.buffer.ybase), 0); + + // No change occurred, don't trigger scroll/refresh + if (oldYdisp === this.buffer.ydisp) { + return; + } + + if (!suppressScrollEvent) { + this.emit('scroll', this.buffer.ydisp); + } + + this.refresh(0, this.rows - 1); + } + + /** + * Scroll the display of the terminal by a number of pages. + * @param {number} pageCount The number of pages to scroll (negative scrolls up). + */ + public scrollPages(pageCount: number): void { + this.scrollDisp(pageCount * (this.rows - 1)); + } + + /** + * Scrolls the display of the terminal to the top. + */ + public scrollToTop(): void { + this.scrollDisp(-this.buffer.ydisp); + } + + /** + * Scrolls the display of the terminal to the bottom. + */ + public scrollToBottom(): void { + this.scrollDisp(this.buffer.ybase - this.buffer.ydisp); + } + + /** + * Writes text to the terminal. + * @param {string} data The text to write to the terminal. + */ + public write(data: string): void { + this.writeBuffer.push(data); + + // Send XOFF to pause the pty process if the write buffer becomes too large so + // xterm.js can catch up before more data is sent. This is necessary in order + // to keep signals such as ^C responsive. + if (this.options.useFlowControl && !this.xoffSentToCatchUp && this.writeBuffer.length >= WRITE_BUFFER_PAUSE_THRESHOLD) { + // XOFF - stop pty pipe + // XON will be triggered by emulator before processing data chunk + this.send(C0.DC3); + this.xoffSentToCatchUp = true; + } + + if (!this.writeInProgress && this.writeBuffer.length > 0) { + // Kick off a write which will write all data in sequence recursively + this.writeInProgress = true; + // Kick off an async innerWrite so more writes can come in while processing data + setTimeout(() => { + this.innerWrite(); + }); + } + } + + private innerWrite(): void { + const writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE); + while (writeBatch.length > 0) { + const data = writeBatch.shift(); + + // If XOFF was sent in order to catch up with the pty process, resume it if + // the writeBuffer is empty to allow more data to come in. + if (this.xoffSentToCatchUp && writeBatch.length === 0 && this.writeBuffer.length === 0) { + this.send(C0.DC1); + this.xoffSentToCatchUp = false; + } + + this.refreshStart = this.buffer.y; + this.refreshEnd = this.buffer.y; + + // HACK: Set the parser state based on it's state at the time of return. + // This works around the bug #662 which saw the parser state reset in the + // middle of parsing escape sequence in two chunks. For some reason the + // state of the parser resets to 0 after exiting parser.parse. This change + // just sets the state back based on the correct return statement. + const state = this.parser.parse(data); + this.parser.setState(state); + + this.updateRange(this.buffer.y); + this.refresh(this.refreshStart, this.refreshEnd); + } + if (this.writeBuffer.length > 0) { + // Allow renderer to catch up before processing the next batch + setTimeout(() => this.innerWrite(), 0); + } else { + this.writeInProgress = false; + } + } + + /** + * Writes text to the terminal, followed by a break line character (\n). + * @param {string} data The text to write to the terminal. + */ + public writeln(data: string): void { + this.write(data + '\r\n'); + } + + /** + * DEPRECATED: only for backward compatibility. Please use attachCustomKeyEventHandler() instead. + * @param {function} customKeydownHandler The custom KeyboardEvent handler to attach. This is a + * function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent + * the default action. The function returns whether the event should be processed by xterm.js. + */ + public attachCustomKeydownHandler(customKeydownHandler: CustomKeyEventHandler): void { + const message = 'attachCustomKeydownHandler() is DEPRECATED and will be removed soon. Please use attachCustomKeyEventHandler() instead.'; + console.warn(message); + this.attachCustomKeyEventHandler(customKeydownHandler); + } + + /** + * Attaches a custom key event handler which is run before keys are processed, giving consumers of + * xterm.js ultimate control as to what keys should be processed by the terminal and what keys + * should not. + * @param {function} customKeyEventHandler The custom KeyboardEvent handler to attach. This is a + * function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent + * the default action. The function returns whether the event should be processed by xterm.js. + */ + public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void { + this.customKeyEventHandler = customKeyEventHandler; + } + + /** + * Attaches a http(s) link handler, forcing web links to behave differently to + * regular tags. This will trigger a refresh as links potentially need to be + * reconstructed. Calling this with null will remove the handler. + * @param handler The handler callback function. + */ + public setHypertextLinkHandler(handler: LinkMatcherHandler): void { + if (!this.linkifier) { + throw new Error('Cannot attach a hypertext link handler before Terminal.open is called'); + } + this.linkifier.setHypertextLinkHandler(handler); + // Refresh to force links to refresh + this.refresh(0, this.rows - 1); + } + + /** + * Attaches a validation callback for hypertext links. This is useful to use + * validation logic or to do something with the link's element and url. + * @param callback The callback to use, this can + * be cleared with null. + */ + public setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void { + if (!this.linkifier) { + throw new Error('Cannot attach a hypertext validation callback before Terminal.open is called'); + } + this.linkifier.setHypertextValidationCallback(callback); + // Refresh to force links to refresh + this.refresh(0, this.rows - 1); + } + + /** + * Registers a link matcher, allowing custom link patterns to be matched and + * handled. + * @param regex The regular expression to search for, specifically + * this searches the textContent of the rows. You will want to use \s to match + * a space ' ' character for example. + * @param handler The callback when the link is called. + * @param [options] Options for the link matcher. + * @return The ID of the new matcher, this can be used to deregister. + */ + public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: ILinkMatcherOptions): number { + if (this.linkifier) { + const matcherId = this.linkifier.registerLinkMatcher(regex, handler, options); + this.refresh(0, this.rows - 1); + return matcherId; + } + } + + /** + * Deregisters a link matcher if it has been registered. + * @param matcherId The link matcher's ID (returned after register) + */ + public deregisterLinkMatcher(matcherId: number): void { + if (this.linkifier) { + if (this.linkifier.deregisterLinkMatcher(matcherId)) { + this.refresh(0, this.rows - 1); + } + } + } + + /** + * Gets whether the terminal has an active selection. + */ + public hasSelection(): boolean { + return this.selectionManager ? this.selectionManager.hasSelection : false; + } + + /** + * Gets the terminal's current selection, this is useful for implementing copy + * behavior outside of xterm.js. + */ + public getSelection(): string { + return this.selectionManager ? this.selectionManager.selectionText : ''; + } + + /** + * Clears the current terminal selection. + */ + public clearSelection(): void { + if (this.selectionManager) { + this.selectionManager.clearSelection(); + } + } + + /** + * Selects all text within the terminal. + */ + public selectAll(): void { + if (this.selectionManager) { + this.selectionManager.selectAll(); + } + } + + /** + * Handle a keydown event + * Key Resources: + * - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent + * @param {KeyboardEvent} ev The keydown event to be handled. + */ + private keyDown(ev: KeyboardEvent): boolean { + if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { + return false; + } + + this.restartCursorBlinking(); + + if (!this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { + if (this.buffer.ybase !== this.buffer.ydisp) { + this.scrollToBottom(); + } + return false; + } + + const result = this.evaluateKeyEscapeSequence(ev); + + if (result.key === C0.DC3) { // XOFF + this.writeStopped = true; + } else if (result.key === C0.DC1) { // XON + this.writeStopped = false; + } + + if (result.scrollDisp) { + this.scrollDisp(result.scrollDisp); + return this.cancel(ev, true); + } + + if (isThirdLevelShift(ev)) { + return true; + } + + if (result.cancel) { + // The event is canceled at the end already, is this necessary? + this.cancel(ev, true); + } + + if (!result.key) { + return true; + } + + this.emit('keydown', ev); + this.emit('key', result.key, ev); + this.showCursor(); + this.handler(result.key); + + return this.cancel(ev, true); + } + + /** + * Returns an object that determines how a KeyboardEvent should be handled. The key of the + * returned value is the new key code to pass to the PTY. + * + * Reference: http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * @param ev The keyboard event to be translated to key escape sequence. + */ + private evaluateKeyEscapeSequence(ev: KeyboardEvent): {cancel: boolean, key: string, scrollDisp: number} { + const result: {cancel: boolean, key: string, scrollDisp: number} = { + // Whether to cancel event propogation (NOTE: this may not be needed since the event is + // canceled at the end of keyDown + cancel: false, + // The new key even to emit + key: undefined, + // The number of characters to scroll, if this is defined it will cancel the event + scrollDisp: undefined + }; + const modifiers = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0) | (ev.metaKey ? 8 : 0); + switch (ev.keyCode) { + case 8: + // backspace + if (ev.shiftKey) { + result.key = C0.BS; // ^H + break; + } + result.key = C0.DEL; // ^? + break; + case 9: + // tab + if (ev.shiftKey) { + result.key = C0.ESC + '[Z'; + break; + } + result.key = C0.HT; + result.cancel = true; + break; + case 13: + // return/enter + result.key = C0.CR; + result.cancel = true; + break; + case 27: + // escape + result.key = C0.ESC; + result.cancel = true; + break; + case 37: + // left-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'D'; + // HACK: Make Alt + left-arrow behave like Ctrl + left-arrow: move one word backwards + // http://unix.stackexchange.com/a/108106 + // macOS uses different escape sequences than linux + if (result.key === C0.ESC + '[1;3D') { + result.key = (this.browser.isMac) ? C0.ESC + 'b' : C0.ESC + '[1;5D'; + } + } else if (this.applicationCursor) { + result.key = C0.ESC + 'OD'; + } else { + result.key = C0.ESC + '[D'; + } + break; + case 39: + // right-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'C'; + // HACK: Make Alt + right-arrow behave like Ctrl + right-arrow: move one word forward + // http://unix.stackexchange.com/a/108106 + // macOS uses different escape sequences than linux + if (result.key === C0.ESC + '[1;3C') { + result.key = (this.browser.isMac) ? C0.ESC + 'f' : C0.ESC + '[1;5C'; + } + } else if (this.applicationCursor) { + result.key = C0.ESC + 'OC'; + } else { + result.key = C0.ESC + '[C'; + } + break; + case 38: + // up-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'A'; + // HACK: Make Alt + up-arrow behave like Ctrl + up-arrow + // http://unix.stackexchange.com/a/108106 + if (result.key === C0.ESC + '[1;3A') { + result.key = C0.ESC + '[1;5A'; + } + } else if (this.applicationCursor) { + result.key = C0.ESC + 'OA'; + } else { + result.key = C0.ESC + '[A'; + } + break; + case 40: + // down-arrow + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'B'; + // HACK: Make Alt + down-arrow behave like Ctrl + down-arrow + // http://unix.stackexchange.com/a/108106 + if (result.key === C0.ESC + '[1;3B') { + result.key = C0.ESC + '[1;5B'; + } + } else if (this.applicationCursor) { + result.key = C0.ESC + 'OB'; + } else { + result.key = C0.ESC + '[B'; + } + break; + case 45: + // insert + if (!ev.shiftKey && !ev.ctrlKey) { + // or + are used to + // copy-paste on some systems. + result.key = C0.ESC + '[2~'; + } + break; + case 46: + // delete + if (modifiers) { + result.key = C0.ESC + '[3;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[3~'; + } + break; + case 36: + // home + if (modifiers) + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'H'; + else if (this.applicationCursor) + result.key = C0.ESC + 'OH'; + else + result.key = C0.ESC + '[H'; + break; + case 35: + // end + if (modifiers) + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'F'; + else if (this.applicationCursor) + result.key = C0.ESC + 'OF'; + else + result.key = C0.ESC + '[F'; + break; + case 33: + // page up + if (ev.shiftKey) { + result.scrollDisp = -(this.rows - 1); + } else { + result.key = C0.ESC + '[5~'; + } + break; + case 34: + // page down + if (ev.shiftKey) { + result.scrollDisp = this.rows - 1; + } else { + result.key = C0.ESC + '[6~'; + } + break; + case 112: + // F1-F12 + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'P'; + } else { + result.key = C0.ESC + 'OP'; + } + break; + case 113: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'Q'; + } else { + result.key = C0.ESC + 'OQ'; + } + break; + case 114: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'R'; + } else { + result.key = C0.ESC + 'OR'; + } + break; + case 115: + if (modifiers) { + result.key = C0.ESC + '[1;' + (modifiers + 1) + 'S'; + } else { + result.key = C0.ESC + 'OS'; + } + break; + case 116: + if (modifiers) { + result.key = C0.ESC + '[15;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[15~'; + } + break; + case 117: + if (modifiers) { + result.key = C0.ESC + '[17;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[17~'; + } + break; + case 118: + if (modifiers) { + result.key = C0.ESC + '[18;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[18~'; + } + break; + case 119: + if (modifiers) { + result.key = C0.ESC + '[19;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[19~'; + } + break; + case 120: + if (modifiers) { + result.key = C0.ESC + '[20;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[20~'; + } + break; + case 121: + if (modifiers) { + result.key = C0.ESC + '[21;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[21~'; + } + break; + case 122: + if (modifiers) { + result.key = C0.ESC + '[23;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[23~'; + } + break; + case 123: + if (modifiers) { + result.key = C0.ESC + '[24;' + (modifiers + 1) + '~'; + } else { + result.key = C0.ESC + '[24~'; + } + break; + default: + // a-z and space + if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ev.keyCode >= 65 && ev.keyCode <= 90) { + result.key = String.fromCharCode(ev.keyCode - 64); + } else if (ev.keyCode === 32) { + // NUL + result.key = String.fromCharCode(0); + } else if (ev.keyCode >= 51 && ev.keyCode <= 55) { + // escape, file sep, group sep, record sep, unit sep + result.key = String.fromCharCode(ev.keyCode - 51 + 27); + } else if (ev.keyCode === 56) { + // delete + result.key = String.fromCharCode(127); + } else if (ev.keyCode === 219) { + // ^[ - Control Sequence Introducer (CSI) + result.key = String.fromCharCode(27); + } else if (ev.keyCode === 220) { + // ^\ - String Terminator (ST) + result.key = String.fromCharCode(28); + } else if (ev.keyCode === 221) { + // ^] - Operating System Command (OSC) + result.key = String.fromCharCode(29); + } + } else if (!this.browser.isMac && ev.altKey && !ev.ctrlKey && !ev.metaKey) { + // On Mac this is a third level shift. Use instead. + if (ev.keyCode >= 65 && ev.keyCode <= 90) { + result.key = C0.ESC + String.fromCharCode(ev.keyCode + 32); + } else if (ev.keyCode === 192) { + result.key = C0.ESC + '`'; + } else if (ev.keyCode >= 48 && ev.keyCode <= 57) { + result.key = C0.ESC + (ev.keyCode - 48); + } + } else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) { + if (ev.keyCode === 65) { // cmd + a + this.selectAll(); + } + } + break; + } + + return result; + } + + /** + * Set the G level of the terminal + * @param g + */ + public setgLevel(g: number): void { + this.glevel = g; + this.charset = this.charsets[g]; + } + + /** + * Set the charset for the given G level of the terminal + * @param g + * @param charset + */ + public setgCharset(g: number, charset: Charset): void { + this.charsets[g] = charset; + if (this.glevel === g) { + this.charset = charset; + } + } + + /** + * Handle a keypress event. + * Key Resources: + * - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent + * @param {KeyboardEvent} ev The keypress event to be handled. + */ + private keyPress(ev: KeyboardEvent): boolean { + let key; + + if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { + return false; + } + + this.cancel(ev); + + if (ev.charCode) { + key = ev.charCode; + } else if (ev.which == null) { + key = ev.keyCode; + } else if (ev.which !== 0 && ev.charCode !== 0) { + key = ev.which; + } else { + return false; + } + + if (!key || ( + (ev.altKey || ev.ctrlKey || ev.metaKey) && !isThirdLevelShift(ev) + )) { + return false; + } + + key = String.fromCharCode(key); + + this.emit('keypress', key, ev); + this.emit('key', key, ev); + this.showCursor(); + this.handler(key); + + return true; + } + + /** + * Send data for handling to the terminal + * @param {string} data + */ + public send(data: string): void { + if (!this.sendDataQueue) { + setTimeout(() => { + this.handler(this.sendDataQueue); + this.sendDataQueue = ''; + }, 1); + } + + this.sendDataQueue += data; + } + + /** + * Ring the bell. + * Note: We could do sweet things with webaudio here + */ + public bell(): void { + if (!this.options.visualBell) return; + this.element.style.borderColor = 'white'; + setTimeout(() => { + this.element.style.borderColor = ''; + }, 10); + if (this.options.popOnBell) this.focus(); + } + + /** + * Log the current state to the console. + */ + public log(text: string, data?: any): void { + if (!this.options.debug) return; + if (!this.context.console || !this.context.console.log) return; + this.context.console.log(text, data); + } + + /** + * Log the current state as error to the console. + */ + public error(text: string, data?: any): void { + if (!this.options.debug) return; + if (!this.context.console || !this.context.console.error) return; + this.context.console.error(text, data); + } + + /** + * Resizes the terminal. + * + * @param {number} x The number of columns to resize to. + * @param {number} y The number of rows to resize to. + */ + public resize(x: number, y: number): void { + if (isNaN(x) || isNaN(y)) { + return; + } + + if (y > this.getOption('scrollback')) { + this.setOption('scrollback', y); + } + + let line; + let el; + let i; + let j; + let ch; + let addToY; + + if (x === this.cols && y === this.rows) { + // Check if we still need to measure the char size (fixes #785). + if (!this.charMeasure.width || !this.charMeasure.height) { + this.charMeasure.measure(); + } + return; + } + + if (x < 1) x = 1; + if (y < 1) y = 1; + + this.buffers.resize(x, y); + + // Adjust rows in the DOM to accurately reflect the new dimensions + while (this.children.length < y) { + this.insertRow(); + } + while (this.children.length > y) { + el = this.children.shift(); + if (!el) continue; + el.parentNode.removeChild(el); + } + + this.cols = x; + this.rows = y; + this.setupStops(this.cols); + + this.charMeasure.measure(); + + this.refresh(0, this.rows - 1); + + this.geometry = [this.cols, this.rows]; + this.emit('resize', {terminal: this, cols: x, rows: y}); + } + + /** + * Updates the range of rows to refresh + * @param {number} y The number of rows to refresh next. + */ + public updateRange(y: number): void { + if (y < this.refreshStart) this.refreshStart = y; + if (y > this.refreshEnd) this.refreshEnd = y; + // if (y > this.refreshEnd) { + // this.refreshEnd = y; + // if (y > this.rows - 1) { + // this.refreshEnd = this.rows - 1; + // } + // } + } + + /** + * Set the range of refreshing to the maximum value + */ + public maxRange(): void { + this.refreshStart = 0; + this.refreshEnd = this.rows - 1; + } + + /** + * Setup the tab stops. + * @param {number} i + */ + public setupStops(i?: number): void { + if (i != null) { + if (!this.buffer.tabs[i]) { + i = this.prevStop(i); + } + } else { + this.buffer.tabs = {}; + i = 0; + } + + for (; i < this.cols; i += this.getOption('tabStopWidth')) { + this.buffer.tabs[i] = true; + } + } + + /** + * Move the cursor to the previous tab stop from the given position (default is current). + * @param {number} x The position to move the cursor to the previous tab stop. + */ + public prevStop(x?: number): number { + if (x == null) x = this.buffer.x; + while (!this.buffer.tabs[--x] && x > 0); + return x >= this.cols ? this.cols - 1 : x < 0 ? 0 : x; + } + + /** + * Move the cursor one tab stop forward from the given position (default is current). + * @param {number} x The position to move the cursor one tab stop forward. + */ + public nextStop(x?: number): number { + if (x == null) x = this.buffer.x; + while (!this.buffer.tabs[++x] && x < this.cols); + return x >= this.cols ? this.cols - 1 : x < 0 ? 0 : x; + } + + /** + * Erase in the identified line everything from "x" to the end of the line (right). + * @param {number} x The column from which to start erasing to the end of the line. + * @param {number} y The line in which to operate. + */ + public eraseRight(x: number, y: number): void { + const line = this.buffer.lines.get(this.buffer.ybase + y); + if (!line) { + return; + } + const ch: CharData = [this.eraseAttr(), ' ', 1]; // xterm + for (; x < this.cols; x++) { + line[x] = ch; + } + this.updateRange(y); + } + + /** + * Erase in the identified line everything from "x" to the start of the line (left). + * @param {number} x The column from which to start erasing to the start of the line. + * @param {number} y The line in which to operate. + */ + public eraseLeft(x: number, y: number): void { + const line = this.buffer.lines.get(this.buffer.ybase + y); + if (!line) { + return; + } + const ch: CharData = [this.eraseAttr(), ' ', 1]; // xterm + x++; + while (x--) { + line[x] = ch; + } + this.updateRange(y); + } + + /** + * Clears the entire buffer, making the prompt line the new first line. + */ + public clear(): void { + if (this.buffer.ybase === 0 && this.buffer.y === 0) { + // Don't clear if it's already clear + return; + } + this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)); + this.buffer.lines.length = 1; + this.buffer.ydisp = 0; + this.buffer.ybase = 0; + this.buffer.y = 0; + for (let i = 1; i < this.rows; i++) { + this.buffer.lines.push(this.blankLine()); + } + this.refresh(0, this.rows - 1); + this.emit('scroll', this.buffer.ydisp); + } + + /** + * Erase all content in the given line + * @param {number} y The line to erase all of its contents. + */ + public eraseLine(y: number): void { + this.eraseRight(0, y); + } + + /** + * Return the data array of a blank line + * @param {boolean} cur First bunch of data for each "blank" character. + * @param {boolean} isWrapped Whether the new line is wrapped from the previous line. + */ + public blankLine(cur?: boolean, isWrapped?: boolean): LineData { + const attr = cur ? this.eraseAttr() : this.defAttr; + + const ch: CharData = [attr, ' ', 1]; // width defaults to 1 halfwidth character + const line: LineData = []; + + // TODO: It is not ideal that this is a property on an array, a buffer line + // class should be added that will hold this data and other useful functions. + if (isWrapped) { + (line).isWrapped = isWrapped; + } + + for (let i = 0; i < this.cols; i++) { + line[i] = ch; + } + + return line; + } + + /** + * If cur return the back color xterm feature attribute. Else return defAttr. + * @param cur + */ + public ch(cur?: boolean): CharData { + return cur ? [this.eraseAttr(), ' ', 1] : [this.defAttr, ' ', 1]; + } + + /** + * Evaluate if the current terminal is the given argument. + * @param term The terminal name to evaluate + */ + public is(term: string): boolean { + return (this.options.termName + '').indexOf(term) === 0; + } + + /** + * Emit the 'data' event and populate the given data. + * @param {string} data The data to populate in the event. + */ + public handler(data: string): void { + // Prevents all events to pty process if stdin is disabled + if (this.options.disableStdin) { + return; + } + + // Clear the selection if the selection manager is available and has an active selection + if (this.selectionManager && this.selectionManager.hasSelection) { + this.selectionManager.clearSelection(); + } + + // Input is being sent to the terminal, the terminal should focus the prompt. + if (this.buffer.ybase !== this.buffer.ydisp) { + this.scrollToBottom(); + } + this.emit('data', data); + } + + /** + * Emit the 'title' event and populate the given title. + * @param {string} title The title to populate in the event. + */ + private 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); + } + + /** + * ESC + */ + + /** + * ESC D Index (IND is 0x84). + */ + public index(): void { + this.buffer.y++; + if (this.buffer.y > this.buffer.scrollBottom) { + this.buffer.y--; + this.scroll(); + } + // If the end of the line is hit, prevent this action from wrapping around to the next line. + if (this.buffer.x >= this.cols) { + this.buffer.x--; + } + } + + /** + * ESC M Reverse Index (RI is 0x8d). + * + * Move the cursor up one row, inserting a new blank line if necessary. + */ + public reverseIndex(): void { + if (this.buffer.y === this.buffer.scrollTop) { + // possibly move the code below to term.reverseScroll(); + // test: echo -ne '\e[1;1H\e[44m\eM\e[0m' + // blankLine(true) is xterm/linux behavior + this.buffer.lines.shiftElements(this.buffer.y + this.buffer.ybase, this.rows - 1, 1); + this.buffer.lines.set(this.buffer.y + this.buffer.ybase, this.blankLine(true)); + this.updateRange(this.buffer.scrollTop); + this.updateRange(this.buffer.scrollBottom); + } else { + this.buffer.y--; + } + } + + /** + * ESC c Full Reset (RIS). + */ + public reset(): void { + this.options.rows = this.rows; + this.options.cols = this.cols; + const customKeyEventHandler = this.customKeyEventHandler; + const cursorBlinkInterval = this.cursorBlinkInterval; + const inputHandler = this.inputHandler; + const buffers = this.buffers; + this.setup(); + this.customKeyEventHandler = customKeyEventHandler; + this.cursorBlinkInterval = cursorBlinkInterval; + this.inputHandler = inputHandler; + this.buffers = buffers; + this.refresh(0, this.rows - 1); + this.viewport.syncScrollArea(); + } + + + /** + * ESC H Tab Set (HTS is 0x88). + */ + private tabSet(): void { + this.buffer.tabs[this.buffer.x] = true; + } + + public cancel(ev: Event, force?: boolean): boolean { + if (!this.options.cancelEvents && !force) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + return false; + } + + // Expose to InputHandler + // TODO: Revise when truecolor is introduced. + public matchColor(r1: number, g1: number, b1: number): number { + const hash = (r1 << 16) | (g1 << 8) | b1; + + if (matchColorCache[hash] != null) { + return matchColorCache[hash]; + } + + let ldiff = Infinity; + let li = -1; + let i = 0; + let c: number[]; + let r2: number; + let g2: number; + let b2: number; + let diff: number; + + for (; i < vcolors.length; i++) { + c = vcolors[i]; + r2 = c[0]; + g2 = c[1]; + b2 = c[2]; + + diff = matchColorDistance(r1, g1, b1, r2, g2, b2); + + if (diff === 0) { + li = i; + break; + } + + if (diff < ldiff) { + ldiff = diff; + li = i; + } + } + + return matchColorCache[hash] = li; + } +} + +/** + * Helpers + */ + +function globalOn(el: any, type: string, handler: (event: Event) => any, capture?: boolean): void { + if (!Array.isArray(el)) { + el = [el]; + } + el.forEach((element: HTMLElement) => { + element.addEventListener(type, handler, capture || false); + }); +} +// TODO: Remove once everything is typed +const on = globalOn; + +function off(el: any, type: string, handler: (event: Event) => any, capture: boolean = false): void { + el.removeEventListener(type, handler, capture); +} + +function isThirdLevelShift(ev: KeyboardEvent): boolean { + const thirdLevelKey = + (Browser.isMac && ev.altKey && !ev.ctrlKey && !ev.metaKey) || + (Browser.isMSWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); + + if (ev.type === 'keypress') { + return thirdLevelKey; + } + + // Don't invoke for arrows, pageDown, home, backspace, etc. (on non-keypress events) + return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47); +} + +const matchColorCache: {[colorRGBHash: number]: number} = {}; + +// http://stackoverflow.com/questions/1633828 +const matchColorDistance = function(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { + return Math.pow(30 * (r1 - r2), 2) + + Math.pow(59 * (g1 - g2), 2) + + Math.pow(11 * (b1 - b2), 2); +}; + +function wasMondifierKeyOnlyEvent(ev: KeyboardEvent): boolean { + return ev.keyCode === 16 || // Shift + ev.keyCode === 17 || // Ctrl + ev.keyCode === 18; // Alt +} diff --git a/src/Types.ts b/src/Types.ts index 896b729a..0de78d52 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -12,3 +12,32 @@ export type LinkMatcher = { }; export type LinkMatcherHandler = (event: MouseEvent, uri: string) => boolean | void; export type LinkMatcherValidationCallback = (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => void; + +export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; +export type Charset = {[key: string]: string}; + +export type CharData = [number, string, number]; +export type LineData = CharData[]; + +export type Option = BooleanOption | StringOption | StringArrayOption | NumberOption | GeometryOption | HandlerOption; +export type BooleanOption = + 'cancelEvents' | + 'convertEol' | + 'cursorBlink' | + 'debug' | + 'disableStdin' | + 'popOnBell' | + 'screenKeys' | + 'useFlowControl' | + 'visualBell'; +export type StringOption = + 'cursorStyle' | + 'termName'; +export type StringArrayOption = 'colors'; +export type NumberOption = + 'cols' | + 'rows' | + 'tabStopWidth' | + 'scrollback'; +export type GeometryOption = 'geometry'; +export type HandlerOption = 'handler'; diff --git a/src/Viewport.test.ts b/src/Viewport.test.ts index 6d09ea86..8c7a914b 100644 --- a/src/Viewport.test.ts +++ b/src/Viewport.test.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { Viewport } from './Viewport'; import {BufferSet} from './BufferSet'; @@ -26,7 +30,9 @@ describe('Viewport', () => { height: 0 } }, - scrollback: 10 + options: { + scrollback: 10 + } }; terminal.buffers = new BufferSet(terminal); terminal.buffer = terminal.buffers.active; diff --git a/src/Viewport.ts b/src/Viewport.ts index fe276935..4ad4ddec 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -2,14 +2,14 @@ * @license MIT */ -import { ITerminal } from './Interfaces'; +import { ITerminal, IViewport } from './Interfaces'; import { CharMeasure } from './utils/CharMeasure'; /** * Represents the viewport of a terminal, the visible area within the larger buffer of output. * Logic for the virtual scroll bar is included in this object. */ -export class Viewport { +export class Viewport implements IViewport { private currentRowHeight: number; private lastRecordedBufferLength: number; private lastRecordedViewportHeight: number; @@ -92,7 +92,7 @@ export class Viewport { * terminal to scroll to it. * @param ev The scroll event. */ - private onScroll(ev: Event) { + private onScroll(ev: Event): void { const newRow = Math.round(this.viewportElement.scrollTop / this.currentRowHeight); const diff = newRow - this.terminal.buffer.ydisp; this.terminal.scrollDisp(diff, true); @@ -104,7 +104,7 @@ export class Viewport { * `Viewport`. * @param ev The mouse wheel event. */ - public onWheel(ev: WheelEvent) { + public onWheel(ev: WheelEvent): void { if (ev.deltaY === 0) { // Do nothing if it's not a vertical scroll event return; @@ -125,7 +125,7 @@ export class Viewport { * Handles the touchstart event, recording the touch occurred. * @param ev The touch event. */ - public onTouchStart(ev: TouchEvent) { + public onTouchStart(ev: TouchEvent): void { this.lastTouchY = ev.touches[0].pageY; }; @@ -133,7 +133,7 @@ export class Viewport { * Handles the touchmove event, scrolling the viewport if the position shifted. * @param ev The touch event. */ - public onTouchMove(ev: TouchEvent) { + public onTouchMove(ev: TouchEvent): void { let deltaY = this.lastTouchY - ev.touches[0].pageY; this.lastTouchY = ev.touches[0].pageY; if (deltaY === 0) { diff --git a/src/handlers/Clipboard.test.ts b/src/handlers/Clipboard.test.ts index 740bee3d..e5bc3581 100644 --- a/src/handlers/Clipboard.test.ts +++ b/src/handlers/Clipboard.test.ts @@ -2,11 +2,11 @@ import { assert } from 'chai'; import * as Terminal from '../xterm'; import * as Clipboard from './Clipboard'; -describe('evaluatePastedTextProcessing', function () { - it('should replace carriage return + line feed with line feed on windows', function () { - const pastedText = 'foo\r\nbar\r\n', - processedText = Clipboard.prepareTextForTerminal(pastedText, false), - windowsProcessedText = Clipboard.prepareTextForTerminal(pastedText, true); +describe('evaluatePastedTextProcessing', () => { + it('should replace carriage return + line feed with line feed on windows', () => { + const pastedText = 'foo\r\nbar\r\n'; + const processedText = Clipboard.prepareTextForTerminal(pastedText, false); + const windowsProcessedText = Clipboard.prepareTextForTerminal(pastedText, true); assert.equal(processedText, 'foo\r\nbar\r\n'); assert.equal(windowsProcessedText, 'foo\rbar\r'); diff --git a/src/handlers/Clipboard.ts b/src/handlers/Clipboard.ts index aa1c1400..ac9a7b87 100644 --- a/src/handlers/Clipboard.ts +++ b/src/handlers/Clipboard.ts @@ -10,7 +10,7 @@ import { ITerminal, ISelectionManager } from '../Interfaces'; interface IWindow extends Window { clipboardData?: { getData(format: string): string; - setData(format: string, data: string); + setData(format: string, data: string): void; }; } @@ -31,7 +31,7 @@ export function prepareTextForTerminal(text: string, isMSWindows: boolean): stri * Binds copy functionality to the given terminal. * @param {ClipboardEvent} ev The original copy event to be handled */ -export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManager: ISelectionManager) { +export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManager: ISelectionManager): void { if (term.browser.isMSIE) { window.clipboardData.setData('Text', selectionManager.selectionText); } else { @@ -47,18 +47,17 @@ export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManage * @param {ClipboardEvent} ev The original paste event to be handled * @param {Terminal} term The terminal on which to apply the handled paste event */ -export function pasteHandler(ev: ClipboardEvent, term: ITerminal) { +export function pasteHandler(ev: ClipboardEvent, term: ITerminal): void { ev.stopPropagation(); let text: string; - let dispatchPaste = function(text) { + let dispatchPaste = function(text: string): void { text = prepareTextForTerminal(text, term.browser.isMSWindows); term.handler(text); term.textarea.value = ''; term.emit('paste', text); - - return term.cancel(ev); + term.cancel(ev); }; if (term.browser.isMSIE) { @@ -79,7 +78,7 @@ export function pasteHandler(ev: ClipboardEvent, term: ITerminal) { * @param ev The original right click event to be handled. * @param textarea The terminal's textarea. */ -export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextAreaElement) { +export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextAreaElement): void { // Bring textarea at the cursor position textarea.style.position = 'fixed'; textarea.style.width = '20px'; @@ -91,7 +90,7 @@ export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextA textarea.focus(); // Reset the terminal textarea's styling - setTimeout(function () { + setTimeout(() => { textarea.style.position = null; textarea.style.width = null; textarea.style.height = null; @@ -107,7 +106,7 @@ export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextA * @param textarea The terminal's textarea. * @param selectionManager The terminal's selection manager. */ -export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager) { +export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager): void { moveTextAreaUnderMouseCursor(ev, textarea); // Get textarea ready to copy from the context menu diff --git a/src/test/escape-sequences-test.js b/src/test/escape-sequences-test.js index b4a53a17..d0447451 100644 --- a/src/test/escape-sequences-test.js +++ b/src/test/escape-sequences-test.js @@ -80,7 +80,7 @@ describe('xterm output comparison', function() { var xterm; beforeEach(function () { - xterm = new Terminal(COLS, ROWS); + xterm = new Terminal({ cols: COLS, rows: ROWS }); xterm.refresh = function() {}; xterm.viewport = { syncScrollArea: function() {} diff --git a/src/test/test.js b/src/test/test.js index c5130e21..5c4dd5f2 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -44,6 +44,16 @@ describe('xterm.js', function() { }); describe('setOption', function() { + let originalWarn; + let warnCallCount; + beforeEach(() => { + originalWarn = console.warn; + warnCallCount = 0; + console.warn = () => warnCallCount++; + }); + afterEach(() => { + console.warn = originalWarn; + }); it('should set the option correctly', function() { xterm.setOption('cursorBlink', true); assert.equal(xterm.cursorBlink, true); @@ -55,10 +65,10 @@ describe('xterm.js', function() { it('should throw when setting a non-existant option', function() { assert.throws(xterm.setOption.bind(xterm, 'fake', true)); }); - it('should not allow scrollback less than number of rows', function() { - let setOptionCall = xterm.setOption.bind(xterm, 'scrollback', xterm.rows - 1); - - assert.equal(setOptionCall(), false); + it('should warn and do nothing when scrollback is less than number of rows', function() { + xterm.setOption('scrollback', xterm.rows - 1); + assert.equal(xterm.getOption('scrollback'), 1000); + assert.equal(warnCallCount, 1); }); }); diff --git a/src/utils/CharMeasure.test.ts b/src/utils/CharMeasure.test.ts index fd4954e0..68e1b6a7 100644 --- a/src/utils/CharMeasure.test.ts +++ b/src/utils/CharMeasure.test.ts @@ -1,6 +1,7 @@ /** * @license MIT */ + import jsdom = require('jsdom'); import { assert } from 'chai'; import { ICharMeasure, ITerminal } from '../Interfaces'; diff --git a/src/utils/CircularList.test.ts b/src/utils/CircularList.test.ts index aaa4a0d9..e27e7f7a 100644 --- a/src/utils/CircularList.test.ts +++ b/src/utils/CircularList.test.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { CircularList } from './CircularList'; diff --git a/src/utils/CircularList.ts b/src/utils/CircularList.ts index d0b2f685..373d78b0 100644 --- a/src/utils/CircularList.ts +++ b/src/utils/CircularList.ts @@ -4,9 +4,11 @@ * @module xterm/utils/CircularList * @license MIT */ -import { EventEmitter } from '../EventEmitter'; -export class CircularList extends EventEmitter { +import { EventEmitter } from '../EventEmitter'; +import { ICircularList } from '../Interfaces'; + +export class CircularList extends EventEmitter implements ICircularList { private _array: T[]; private _startIndex: number; private _length: number; diff --git a/src/utils/DomElementObjectPool.test.ts b/src/utils/DomElementObjectPool.test.ts index 7298f192..5a5d8a78 100644 --- a/src/utils/DomElementObjectPool.test.ts +++ b/src/utils/DomElementObjectPool.test.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + import { assert } from 'chai'; import { DomElementObjectPool } from './DomElementObjectPool'; diff --git a/src/utils/Generic.ts b/src/utils/Generic.ts index ce09c1be..98072326 100644 --- a/src/utils/Generic.ts +++ b/src/utils/Generic.ts @@ -9,6 +9,6 @@ * @param {Array} array The array to search for the given element. * @param {Object} el The element to look for into the array */ -export function contains(arr: any[], el: any) { +export function contains(arr: any[], el: any): boolean { return arr.indexOf(el) >= 0; }; diff --git a/src/utils/TestUtils.ts b/src/utils/TestUtils.ts new file mode 100644 index 00000000..159da055 --- /dev/null +++ b/src/utils/TestUtils.ts @@ -0,0 +1,183 @@ +/** + * @license MIT + */ + +import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList } from '../Interfaces'; +import { LineData } from '../Types'; + +export class MockTerminal implements ITerminal { + options: ITerminalOptions = {}; + element: HTMLElement; + rowContainer: HTMLElement; + selectionContainer: HTMLElement; + selectionManager: ISelectionManager; + charMeasure: ICharMeasure; + textarea: HTMLTextAreaElement; + rows: number; + cols: number; + browser: IBrowser; + writeBuffer: string[]; + children: HTMLElement[]; + cursorHidden: boolean; + cursorState: number; + defAttr: number; + scrollback: number; + buffers: IBufferSet; + buffer: IBuffer; + handler(data: string): void { + throw new Error('Method not implemented.'); + } + on(event: string, callback: () => void): void { + throw new Error('Method not implemented.'); + } + off(type: string, listener: IListenerType): void { + throw new Error('Method not implemented.'); + } + scrollDisp(disp: number, suppressScrollEvent: boolean): void { + throw new Error('Method not implemented.'); + } + cancel(ev: Event, force?: boolean): void { + throw new Error('Method not implemented.'); + } + log(text: string): void { + throw new Error('Method not implemented.'); + } + emit(event: string, data: any): void { + throw new Error('Method not implemented.'); + } + reset(): void { + throw new Error('Method not implemented.'); + } + showCursor(): void { + throw new Error('Method not implemented.'); + } + blankLine(cur?: boolean, isWrapped?: boolean): LineData { + const line: LineData = []; + for (let i = 0; i < this.cols; i++) { + line.push([0, ' ', 1]); + } + return line; + } +} + +export class MockInputHandlingTerminal implements IInputHandlingTerminal { + element: HTMLElement; + options: ITerminalOptions = {}; + cols: number; + rows: number; + charset: { [key: string]: string; }; + gcharset: number; + glevel: number; + charsets: { [key: string]: string; }[]; + applicationKeypad: boolean; + applicationCursor: boolean; + originMode: boolean; + insertMode: boolean; + wraparoundMode: boolean; + defAttr: number; + curAttr: number; + prefix: string; + savedCols: number; + x10Mouse: boolean; + vt200Mouse: boolean; + normalMouse: boolean; + mouseEvents: boolean; + sendFocus: boolean; + utfMouse: boolean; + sgrMouse: boolean; + urxvtMouse: boolean; + cursorHidden: boolean; + buffers: IBufferSet; + buffer: IBuffer = new MockBuffer(); + viewport: IViewport; + selectionManager: ISelectionManager; + focus(): void { + throw new Error('Method not implemented.'); + } + convertEol: boolean; + updateRange(y: number): void { + throw new Error('Method not implemented.'); + } + scroll(isWrapped?: boolean): void { + throw new Error('Method not implemented.'); + } + nextStop(x?: number): number { + throw new Error('Method not implemented.'); + } + setgLevel(g: number): void { + throw new Error('Method not implemented.'); + } + eraseAttr(): number { + throw new Error('Method not implemented.'); + } + eraseRight(x: number, y: number): void { + throw new Error('Method not implemented.'); + } + eraseLine(y: number): void { + throw new Error('Method not implemented.'); + } + eraseLeft(x: number, y: number): void { + throw new Error('Method not implemented.'); + } + blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number][] { + throw new Error('Method not implemented.'); + } + prevStop(x?: number): number { + throw new Error('Method not implemented.'); + } + is(term: string): boolean { + throw new Error('Method not implemented.'); + } + send(data: string): void { + throw new Error('Method not implemented.'); + } + setgCharset(g: number, charset: { [key: string]: string; }): void { + throw new Error('Method not implemented.'); + } + resize(x: number, y: number): void { + throw new Error('Method not implemented.'); + } + log(text: string, data?: any): void { + throw new Error('Method not implemented.'); + } + reset(): void { + throw new Error('Method not implemented.'); + } + showCursor(): void { + throw new Error('Method not implemented.'); + } + refresh(start: number, end: number): void { + throw new Error('Method not implemented.'); + } + matchColor(r1: number, g1: number, b1: number): number { + throw new Error('Method not implemented.'); + } + error(text: string, data?: any): void { + throw new Error('Method not implemented.'); + } + setOption(key: string, value: any): void { + this.options[key] = value; + } + on(type: string, listener: IListenerType): void { + throw new Error('Method not implemented.'); + } + off(type: string, listener: IListenerType): void { + throw new Error('Method not implemented.'); + } + emit(type: string, data?: any): void { + throw new Error('Method not implemented.'); + } +} + +export class MockBuffer implements IBuffer { + lines: ICircularList<[number, string, number][]>; + ydisp: number; + ybase: number; + y: number; + x: number; + tabs: any; + scrollBottom: number; + scrollTop: number; + savedY: number; + savedX: number; +} diff --git a/src/xterm.js b/src/xterm.js deleted file mode 100644 index 5aa069e7..00000000 --- a/src/xterm.js +++ /dev/null @@ -1,2461 +0,0 @@ -/** - * xterm.js: xterm, in the browser - * Originally forked from (with the author's permission): - * Fabrice Bellard's javascript vt100 for jslinux: - * http://bellard.org/jslinux/ - * Copyright (c) 2011 Fabrice Bellard - * The original design remains. The terminal itself - * has been extended to include xterm CSI codes, among - * other features. - * @license MIT - */ - -import { BufferSet } from './BufferSet'; -import { CompositionHelper } from './CompositionHelper'; -import { EventEmitter } from './EventEmitter'; -import { Viewport } from './Viewport'; -import { rightClickHandler, moveTextAreaUnderMouseCursor, pasteHandler, copyHandler } from './handlers/Clipboard'; -import { CircularList } from './utils/CircularList'; -import { C0 } from './EscapeSequences'; -import { InputHandler } from './InputHandler'; -import { Parser } from './Parser'; -import { Renderer } from './Renderer'; -import { Linkifier } from './Linkifier'; -import { SelectionManager } from './SelectionManager'; -import { CharMeasure } from './utils/CharMeasure'; -import * as Browser from './utils/Browser'; -import * as Mouse from './utils/Mouse'; -import { CHARSETS } from './Charsets'; -import { getRawByteCoords } from './utils/Mouse'; -import { translateBufferLineToString } from './utils/BufferLine'; - -/** - * Terminal Emulation References: - * http://vt100.net/ - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html - * http://invisible-island.net/vttest/ - * http://www.inwap.com/pdp10/ansicode.txt - * http://linux.die.net/man/4/console_codes - * http://linux.die.net/man/7/urxvt - */ - -// Let it work inside Node.js for automated testing purposes. -var document = (typeof window != 'undefined') ? window.document : null; - -/** - * The amount of write requests to queue before sending an XOFF signal to the - * pty process. This number must be small in order for ^C and similar sequences - * to be responsive. - */ -var WRITE_BUFFER_PAUSE_THRESHOLD = 5; - -/** - * The number of writes to perform in a single batch before allowing the - * renderer to catch up with a 0ms setTimeout. - */ -var WRITE_BATCH_SIZE = 300; - -/** - * The time between cursor blinks. This is driven by JS rather than a CSS - * animation due to a bug in Chromium that causes it to use excessive CPU time. - * See https://github.com/Microsoft/vscode/issues/22900 - */ -var CURSOR_BLINK_INTERVAL = 600; - -/** - * Terminal - */ - -/** - * Creates a new `Terminal` object. - * - * @param {object} options An object containing a set of options, the available options are: - * - `cursorBlink` (boolean): Whether the terminal cursor blinks - * - `cols` (number): The number of columns of the terminal (horizontal size) - * - `rows` (number): The number of rows of the terminal (vertical size) - * - * @public - * @class Xterm Xterm - * @alias module:xterm/src/xterm - */ -function Terminal(options) { - var self = this; - - if (!(this instanceof Terminal)) { - return new Terminal(arguments[0], arguments[1], arguments[2]); - } - - self.browser = Browser; - self.cancel = Terminal.cancel; - - EventEmitter.call(this); - - if (typeof options === 'number') { - options = { - cols: arguments[0], - rows: arguments[1], - handler: arguments[2] - }; - } - - options = options || {}; - - - Object.keys(Terminal.defaults).forEach(function(key) { - if (options[key] == null) { - options[key] = Terminal.options[key]; - - if (Terminal[key] !== Terminal.defaults[key]) { - options[key] = Terminal[key]; - } - } - self[key] = options[key]; - }); - - if (options.colors.length === 8) { - options.colors = options.colors.concat(Terminal._colors.slice(8)); - } else if (options.colors.length === 16) { - options.colors = options.colors.concat(Terminal._colors.slice(16)); - } else if (options.colors.length === 10) { - options.colors = options.colors.slice(0, -2).concat( - Terminal._colors.slice(8, -2), options.colors.slice(-2)); - } else if (options.colors.length === 18) { - options.colors = options.colors.concat( - Terminal._colors.slice(16, -2), options.colors.slice(-2)); - } - this.colors = options.colors; - - this.options = options; - - // this.context = options.context || window; - // this.document = options.document || document; - this.parent = options.body || options.parent || ( - document ? document.getElementsByTagName('body')[0] : null - ); - - this.cols = options.cols || options.geometry[0]; - this.rows = options.rows || options.geometry[1]; - this.geometry = [this.cols, this.rows]; - - if (options.handler) { - this.on('data', options.handler); - } - - this.cursorState = 0; - this.cursorHidden = false; - this.convertEol; - this.queue = ''; - this.customKeyEventHandler = null; - this.cursorBlinkInterval = null; - - // modes - this.applicationKeypad = false; - this.applicationCursor = false; - this.originMode = false; - this.insertMode = false; - this.wraparoundMode = true; // defaults: xterm - true, vt100 - false - - // charset - this.charset = null; - this.gcharset = null; - this.glevel = 0; - this.charsets = [null]; - - // mouse properties - this.decLocator; - this.x10Mouse; - this.vt200Mouse; - this.vt300Mouse; - this.normalMouse; - this.mouseEvents; - this.sendFocus; - this.utfMouse; - this.sgrMouse; - this.urxvtMouse; - - // misc - this.element; - this.children; - this.refreshStart; - this.refreshEnd; - this.savedX; - this.savedY; - this.savedCols; - - // stream - this.readable = true; - this.writable = true; - - this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); - this.curAttr = this.defAttr; - - this.params = []; - this.currentParam = 0; - this.prefix = ''; - this.postfix = ''; - - this.inputHandler = new InputHandler(this); - this.parser = new Parser(this.inputHandler, this); - // Reuse renderer if the Terminal is being recreated via a Terminal.reset call. - this.renderer = this.renderer || null; - this.selectionManager = this.selectionManager || null; - this.linkifier = this.linkifier || new Linkifier(); - - // user input states - this.writeBuffer = []; - this.writeInProgress = false; - - /** - * Whether _xterm.js_ sent XOFF in order to catch up with the pty process. - * This is a distinct state from writeStopped so that if the user requested - * XOFF via ^S that it will not automatically resume when the writeBuffer goes - * below threshold. - */ - this.xoffSentToCatchUp = false; - - /** Whether writing has been stopped as a result of XOFF */ - this.writeStopped = false; - - // leftover surrogate high from previous write invocation - this.surrogate_high = ''; - - // Create the terminal's buffers and set the current buffer - this.buffers = new BufferSet(this); - this.buffer = this.buffers.active; // Convenience shortcut; - this.buffers.on('activate', function (buffer) { - this._terminal.buffer = buffer; - }); - - var i = this.rows; - - while (i--) { - this.buffer.lines.push(this.blankLine()); - } - // Ensure the selection manager has the correct buffer - if (this.selectionManager) { - this.selectionManager.setBuffer(this.buffer.lines); - } - - this.setupStops(); - - // Store if user went browsing history in scrollback - this.userScrolling = false; -} - -inherits(Terminal, EventEmitter); - -/** - * back_color_erase feature for xterm. - */ -Terminal.prototype.eraseAttr = function() { - // if (this.is('screen')) return this.defAttr; - return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); -}; - -/** - * Colors - */ - -// Colors 0-15 -Terminal.tangoColors = [ - // dark: - '#2e3436', - '#cc0000', - '#4e9a06', - '#c4a000', - '#3465a4', - '#75507b', - '#06989a', - '#d3d7cf', - // bright: - '#555753', - '#ef2929', - '#8ae234', - '#fce94f', - '#729fcf', - '#ad7fa8', - '#34e2e2', - '#eeeeec' -]; - -// Colors 0-15 + 16-255 -// Much thanks to TooTallNate for writing this. -Terminal.colors = (function() { - var colors = Terminal.tangoColors.slice() - , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] - , i; - - // 16-231 - i = 0; - for (; i < 216; i++) { - out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); - } - - // 232-255 (grey) - i = 0; - for (; i < 24; i++) { - r = 8 + i * 10; - out(r, r, r); - } - - function out(r, g, b) { - colors.push('#' + hex(r) + hex(g) + hex(b)); - } - - function hex(c) { - c = c.toString(16); - return c.length < 2 ? '0' + c : c; - } - - return colors; -})(); - -Terminal._colors = Terminal.colors.slice(); - -Terminal.vcolors = (function() { - var out = [] - , colors = Terminal.colors - , i = 0 - , color; - - for (; i < 256; i++) { - color = parseInt(colors[i].substring(1), 16); - out.push([ - (color >> 16) & 0xff, - (color >> 8) & 0xff, - color & 0xff - ]); - } - - return out; -})(); - -/** - * Options - */ - -Terminal.defaults = { - colors: Terminal.colors, - theme: 'default', - convertEol: false, - termName: 'xterm', - geometry: [80, 24], - cursorBlink: false, - cursorStyle: 'block', - visualBell: false, - popOnBell: false, - scrollback: 1000, - screenKeys: false, - debug: false, - cancelEvents: false, - disableStdin: false, - useFlowControl: false, - tabStopWidth: 8 - // programFeatures: false, - // focusKeys: false, -}; - -Terminal.options = {}; - -Terminal.focus = null; - -each(keys(Terminal.defaults), function(key) { - Terminal[key] = Terminal.defaults[key]; - Terminal.options[key] = Terminal.defaults[key]; -}); - -/** - * Focus the terminal. Delegates focus handling to the terminal's DOM element. - */ -Terminal.prototype.focus = function() { - return this.textarea.focus(); -}; - -/** - * Retrieves an option's value from the terminal. - * @param {string} key The option key. - */ -Terminal.prototype.getOption = function(key) { - if (!(key in Terminal.defaults)) { - throw new Error('No option with key "' + key + '"'); - } - - if (typeof this.options[key] !== 'undefined') { - return this.options[key]; - } - - return this[key]; -}; - -/** - * Sets an option on the terminal. - * @param {string} key The option key. - * @param {string} value The option value. - */ -Terminal.prototype.setOption = function(key, value) { - if (!(key in Terminal.defaults)) { - throw new Error('No option with key "' + key + '"'); - } - switch (key) { - case 'scrollback': - if (value < this.rows) { - let msg = 'Setting the scrollback value less than the number of rows '; - - msg += `(${this.rows}) is not allowed.`; - - console.warn(msg); - return false; - } - - if (this.options[key] !== value) { - if (this.buffer.lines.length > value) { - const amountToTrim = this.buffer.lines.length - value; - const needsRefresh = (this.buffer.ydisp - amountToTrim < 0); - this.buffer.lines.trimStart(amountToTrim); - this.buffer.ybase = Math.max(this.buffer.ybase - amountToTrim, 0); - this.buffer.ydisp = Math.max(this.buffer.ydisp - amountToTrim, 0); - if (needsRefresh) { - this.refresh(0, this.rows - 1); - } - } - this.buffer.lines.maxLength = value; - this.viewport.syncScrollArea(); - } - break; - } - this[key] = value; - this.options[key] = value; - switch (key) { - case 'cursorBlink': this.setCursorBlinking(value); break; - case 'cursorStyle': - this.element.classList.toggle(`xterm-cursor-style-block`, value === 'block'); - this.element.classList.toggle(`xterm-cursor-style-underline`, value === 'underline'); - this.element.classList.toggle(`xterm-cursor-style-bar`, value === 'bar'); - break; - case 'tabStopWidth': this.setupStops(); break; - } -}; - -Terminal.prototype.restartCursorBlinking = function () { - this.setCursorBlinking(this.options.cursorBlink); -}; - -Terminal.prototype.setCursorBlinking = function (enabled) { - this.element.classList.toggle('xterm-cursor-blink', enabled); - this.clearCursorBlinkingInterval(); - if (enabled) { - var self = this; - this.cursorBlinkInterval = setInterval(function () { - self.element.classList.toggle('xterm-cursor-blink-on'); - }, CURSOR_BLINK_INTERVAL); - } -}; - -Terminal.prototype.clearCursorBlinkingInterval = function () { - this.element.classList.remove('xterm-cursor-blink-on'); - if (this.cursorBlinkInterval) { - clearInterval(this.cursorBlinkInterval); - this.cursorBlinkInterval = null; - } -}; - -/** - * Binds the desired focus behavior on a given terminal object. - * - * @static - */ -Terminal.bindFocus = function (term) { - on(term.textarea, 'focus', function (ev) { - if (term.sendFocus) { - term.send(C0.ESC + '[I'); - } - term.element.classList.add('focus'); - term.showCursor(); - term.restartCursorBlinking.apply(term); - Terminal.focus = term; - term.emit('focus', {terminal: term}); - }); -}; - -/** - * Blur the terminal. Delegates blur handling to the terminal's DOM element. - */ -Terminal.prototype.blur = function() { - return this.textarea.blur(); -}; - -/** - * Binds the desired blur behavior on a given terminal object. - * - * @static - */ -Terminal.bindBlur = function (term) { - on(term.textarea, 'blur', function (ev) { - term.refresh(term.buffer.y, term.buffer.y); - if (term.sendFocus) { - term.send(C0.ESC + '[O'); - } - term.element.classList.remove('focus'); - term.clearCursorBlinkingInterval.apply(term); - Terminal.focus = null; - term.emit('blur', {terminal: term}); - }); -}; - -/** - * Initialize default behavior - */ -Terminal.prototype.initGlobal = function() { - var term = this; - - Terminal.bindKeys(this); - Terminal.bindFocus(this); - Terminal.bindBlur(this); - - // Bind clipboard functionality - on(this.element, 'copy', event => { - // If mouse events are active it means the selection manager is disabled and - // copy should be handled by the host program. - if (!term.hasSelection()) { - return; - } - copyHandler(event, term, this.selectionManager); - }); - const pasteHandlerWrapper = event => pasteHandler(event, term); - on(this.textarea, 'paste', pasteHandlerWrapper); - on(this.element, 'paste', pasteHandlerWrapper); - - // Handle right click context menus - if (term.browser.isFirefox) { - // Firefox doesn't appear to fire the contextmenu event on right click - on(this.element, 'mousedown', event => { - if (event.button == 2) { - rightClickHandler(event, this.textarea, this.selectionManager); - } - }); - } else { - on(this.element, 'contextmenu', event => { - rightClickHandler(event, this.textarea, this.selectionManager); - }); - } - - // Move the textarea under the cursor when middle clicking on Linux to ensure - // middle click to paste selection works. This only appears to work in Chrome - // at the time is writing. - if (term.browser.isLinux) { - // Use auxclick event over mousedown the latter doesn't seem to work. Note - // that the regular click event doesn't fire for the middle mouse button. - on(this.element, 'auxclick', event => { - if (event.button === 1) { - moveTextAreaUnderMouseCursor(event, this.textarea, this.selectionManager); - } - }); - } -}; - -/** - * Apply key handling to the terminal - */ -Terminal.bindKeys = function(term) { - on(term.element, 'keydown', function(ev) { - if (document.activeElement != this) { - return; - } - term.keyDown(ev); - }, true); - - on(term.element, 'keypress', function(ev) { - if (document.activeElement != this) { - return; - } - term.keyPress(ev); - }, true); - - on(term.element, 'keyup', function(ev) { - if (!wasMondifierKeyOnlyEvent(ev)) { - term.focus(term); - } - }, true); - - on(term.textarea, 'keydown', function(ev) { - term.keyDown(ev); - }, true); - - on(term.textarea, 'keypress', function(ev) { - term.keyPress(ev); - // Truncate the textarea's value, since it is not needed - this.value = ''; - }, true); - - on(term.textarea, 'compositionstart', term.compositionHelper.compositionstart.bind(term.compositionHelper)); - on(term.textarea, 'compositionupdate', term.compositionHelper.compositionupdate.bind(term.compositionHelper)); - on(term.textarea, 'compositionend', term.compositionHelper.compositionend.bind(term.compositionHelper)); - term.on('refresh', term.compositionHelper.updateCompositionElements.bind(term.compositionHelper)); - term.on('refresh', function (data) { - term.queueLinkification(data.start, data.end) - }); -}; - - -/** - * Insert the given row to the terminal or produce a new one - * if no row argument is passed. Return the inserted row. - * @param {HTMLElement} row (optional) The row to append to the terminal. - */ -Terminal.prototype.insertRow = function (row) { - if (typeof row != 'object') { - row = document.createElement('div'); - } - - this.rowContainer.appendChild(row); - this.children.push(row); - - return row; -}; - -/** - * Opens the terminal within an element. - * - * @param {HTMLElement} parent The element to create the terminal within. - * @param {boolean} focus Focus the terminal, after it gets instantiated in the DOM - */ -Terminal.prototype.open = function(parent, focus) { - var self=this, i=0, div; - - this.parent = parent || this.parent; - - if (!this.parent) { - throw new Error('Terminal requires a parent element.'); - } - - // Grab global elements - this.context = this.parent.ownerDocument.defaultView; - this.document = this.parent.ownerDocument; - this.body = this.document.getElementsByTagName('body')[0]; - - //Create main element container - this.element = this.document.createElement('div'); - this.element.classList.add('terminal'); - this.element.classList.add('xterm'); - this.element.classList.add('xterm-theme-' + this.theme); - this.element.classList.add(`xterm-cursor-style-${this.options.cursorStyle}`); - this.setCursorBlinking(this.options.cursorBlink); - - this.element.setAttribute('tabindex', 0); - - this.viewportElement = document.createElement('div'); - this.viewportElement.classList.add('xterm-viewport'); - this.element.appendChild(this.viewportElement); - this.viewportScrollArea = document.createElement('div'); - this.viewportScrollArea.classList.add('xterm-scroll-area'); - this.viewportElement.appendChild(this.viewportScrollArea); - - // Create the selection container. - this.selectionContainer = document.createElement('div'); - this.selectionContainer.classList.add('xterm-selection'); - this.element.appendChild(this.selectionContainer); - - // Create the container that will hold the lines of the terminal and then - // produce the lines the lines. - this.rowContainer = document.createElement('div'); - this.rowContainer.classList.add('xterm-rows'); - this.element.appendChild(this.rowContainer); - this.children = []; - this.linkifier.attachToDom(document, this.children); - - // Create the container that will hold helpers like the textarea for - // capturing DOM Events. Then produce the helpers. - this.helperContainer = document.createElement('div'); - this.helperContainer.classList.add('xterm-helpers'); - // TODO: This should probably be inserted once it's filled to prevent an additional layout - this.element.appendChild(this.helperContainer); - this.textarea = document.createElement('textarea'); - this.textarea.classList.add('xterm-helper-textarea'); - this.textarea.setAttribute('autocorrect', 'off'); - this.textarea.setAttribute('autocapitalize', 'off'); - this.textarea.setAttribute('spellcheck', 'false'); - this.textarea.tabIndex = 0; - this.textarea.addEventListener('focus', function() { - self.emit('focus', {terminal: self}); - }); - this.textarea.addEventListener('blur', function() { - self.emit('blur', {terminal: self}); - }); - this.helperContainer.appendChild(this.textarea); - - this.compositionView = document.createElement('div'); - this.compositionView.classList.add('composition-view'); - this.compositionHelper = new CompositionHelper(this.textarea, this.compositionView, this); - this.helperContainer.appendChild(this.compositionView); - - this.charSizeStyleElement = document.createElement('style'); - this.helperContainer.appendChild(this.charSizeStyleElement); - - for (; i < this.rows; i++) { - this.insertRow(); - } - this.parent.appendChild(this.element); - - this.charMeasure = new CharMeasure(document, this.helperContainer); - this.charMeasure.on('charsizechanged', function () { - self.updateCharSizeStyles(); - }); - this.charMeasure.measure(); - - this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); - this.renderer = new Renderer(this); - this.selectionManager = new SelectionManager( - this, this.buffer.lines, this.rowContainer, this.charMeasure - ); - this.selectionManager.on('refresh', data => { - this.renderer.refreshSelection(data.start, data.end); - }); - this.selectionManager.on('newselection', text => { - // If there's a new selection, put it into the textarea, focus and select it - // in order to register it as a selection on the OS. This event is fired - // only on Linux to enable middle click to paste selection. - this.textarea.value = text; - this.textarea.focus(); - this.textarea.select(); - }); - this.on('scroll', () => this.selectionManager.refresh()); - this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); - - // Setup loop that draws to screen - this.refresh(0, this.rows - 1); - - // Initialize global actions that - // need to be taken on the document. - this.initGlobal(); - - /** - * Automatic focus functionality. - * TODO: Default to `false` starting with xterm.js 3.0. - */ - if (typeof focus == 'undefined') { - let message = 'You did not pass the `focus` argument in `Terminal.prototype.open()`.\n'; - - message += 'The `focus` argument now defaults to `true` but starting with xterm.js 3.0 '; - message += 'it will default to `false`.'; - - console.warn(message); - focus = true; - } - - if (focus) { - this.focus(); - } - - // Listen for mouse events and translate - // them into terminal mouse protocols. - this.bindMouse(); - - /** - * This event is emitted when terminal has completed opening. - * - * @event open - */ - this.emit('open'); -}; - - -/** - * Attempts to load an add-on using CommonJS or RequireJS (whichever is available). - * @param {string} addon The name of the addon to load - * @static - */ -Terminal.loadAddon = function(addon, callback) { - if (typeof exports === 'object' && typeof module === 'object') { - // CommonJS - return require('./addons/' + addon + '/' + addon); - } else if (typeof define == 'function') { - // RequireJS - return require(['./addons/' + addon + '/' + addon], callback); - } else { - console.error('Cannot load a module without a CommonJS or RequireJS environment.'); - return false; - } -}; - -/** - * Updates the helper CSS class with any changes necessary after the terminal's - * character width has been changed. - */ -Terminal.prototype.updateCharSizeStyles = function() { - this.charSizeStyleElement.textContent = - `.xterm-wide-char{width:${this.charMeasure.width * 2}px;}` + - `.xterm-normal-char{width:${this.charMeasure.width}px;}` + - `.xterm-rows > div{height:${this.charMeasure.height}px;}`; -} - -/** - * XTerm mouse events - * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking - * To better understand these - * the xterm code is very helpful: - * Relevant files: - * button.c, charproc.c, misc.c - * Relevant functions in xterm/button.c: - * BtnCode, EmitButtonCode, EditorButton, SendMousePosition - */ -Terminal.prototype.bindMouse = function() { - var el = this.element, self = this, pressed = 32; - - // mouseup, mousedown, wheel - // left click: ^[[M 3<^[[M#3< - // wheel up: ^[[M`3> - function sendButton(ev) { - var button - , pos; - - // get the xterm-style button - button = getButton(ev); - - // get mouse coordinates - pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); - if (!pos) return; - - sendEvent(button, pos); - - switch (ev.overrideType || ev.type) { - case 'mousedown': - pressed = button; - break; - case 'mouseup': - // keep it at the left - // button, just in case. - pressed = 32; - break; - case 'wheel': - // nothing. don't - // interfere with - // `pressed`. - break; - } - } - - // motion example of a left click: - // ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7< - function sendMove(ev) { - var button = pressed - , pos; - - pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); - if (!pos) return; - - // buttons marked as motions - // are incremented by 32 - button += 32; - - sendEvent(button, pos); - } - - // encode button and - // position to characters - function encode(data, ch) { - if (!self.utfMouse) { - if (ch === 255) return data.push(0); - if (ch > 127) ch = 127; - data.push(ch); - } else { - if (ch === 2047) return data.push(0); - if (ch < 127) { - data.push(ch); - } else { - if (ch > 2047) ch = 2047; - data.push(0xC0 | (ch >> 6)); - data.push(0x80 | (ch & 0x3F)); - } - } - } - - // send a mouse event: - // regular/utf8: ^[[M Cb Cx Cy - // urxvt: ^[[ Cb ; Cx ; Cy M - // sgr: ^[[ Cb ; Cx ; Cy M/m - // vt300: ^[[ 24(1/3/5)~ [ Cx , Cy ] \r - // locator: CSI P e ; P b ; P r ; P c ; P p & w - function sendEvent(button, pos) { - // self.emit('mouse', { - // x: pos.x - 32, - // y: pos.x - 32, - // button: button - // }); - - if (self.vt300Mouse) { - // NOTE: Unstable. - // http://www.vt100.net/docs/vt3xx-gp/chapter15.html - button &= 3; - pos.x -= 32; - pos.y -= 32; - var data = C0.ESC + '[24'; - if (button === 0) data += '1'; - else if (button === 1) data += '3'; - else if (button === 2) data += '5'; - else if (button === 3) return; - else data += '0'; - data += '~[' + pos.x + ',' + pos.y + ']\r'; - self.send(data); - return; - } - - if (self.decLocator) { - // NOTE: Unstable. - button &= 3; - pos.x -= 32; - pos.y -= 32; - if (button === 0) button = 2; - else if (button === 1) button = 4; - else if (button === 2) button = 6; - else if (button === 3) button = 3; - self.send(C0.ESC + '[' - + button - + ';' - + (button === 3 ? 4 : 0) - + ';' - + pos.y - + ';' - + pos.x - + ';' - + (pos.page || 0) - + '&w'); - return; - } - - if (self.urxvtMouse) { - pos.x -= 32; - pos.y -= 32; - pos.x++; - pos.y++; - self.send(C0.ESC + '[' + button + ';' + pos.x + ';' + pos.y + 'M'); - return; - } - - if (self.sgrMouse) { - pos.x -= 32; - pos.y -= 32; - self.send(C0.ESC + '[<' - + (((button & 3) === 3 ? button & ~3 : button) - 32) - + ';' - + pos.x - + ';' - + pos.y - + ((button & 3) === 3 ? 'm' : 'M')); - return; - } - - var data = []; - - encode(data, button); - encode(data, pos.x); - encode(data, pos.y); - - self.send(C0.ESC + '[M' + String.fromCharCode.apply(String, data)); - } - - function getButton(ev) { - var button - , shift - , meta - , ctrl - , mod; - - // two low bits: - // 0 = left - // 1 = middle - // 2 = right - // 3 = release - // wheel up/down: - // 1, and 2 - with 64 added - switch (ev.overrideType || ev.type) { - case 'mousedown': - button = ev.button != null - ? +ev.button - : ev.which != null - ? ev.which - 1 - : null; - - if (self.browser.isMSIE) { - button = button === 1 ? 0 : button === 4 ? 1 : button; - } - break; - case 'mouseup': - button = 3; - break; - case 'DOMMouseScroll': - button = ev.detail < 0 - ? 64 - : 65; - break; - case 'wheel': - button = ev.wheelDeltaY > 0 - ? 64 - : 65; - break; - } - - // next three bits are the modifiers: - // 4 = shift, 8 = meta, 16 = control - shift = ev.shiftKey ? 4 : 0; - meta = ev.metaKey ? 8 : 0; - ctrl = ev.ctrlKey ? 16 : 0; - mod = shift | meta | ctrl; - - // no mods - if (self.vt200Mouse) { - // ctrl only - mod &= ctrl; - } else if (!self.normalMouse) { - mod = 0; - } - - // increment to SP - button = (32 + (mod << 2)) + button; - - return button; - } - - on(el, 'mousedown', function(ev) { - - // Prevent the focus on the textarea from getting lost - // and make sure we get focused on mousedown - ev.preventDefault(); - self.focus(); - - if (!self.mouseEvents) return; - - // send the button - sendButton(ev); - - // fix for odd bug - //if (self.vt200Mouse && !self.normalMouse) { - if (self.vt200Mouse) { - ev.overrideType = 'mouseup'; - sendButton(ev); - return self.cancel(ev); - } - - // bind events - if (self.normalMouse) on(self.document, 'mousemove', sendMove); - - // x10 compatibility mode can't send button releases - if (!self.x10Mouse) { - on(self.document, 'mouseup', function up(ev) { - sendButton(ev); - if (self.normalMouse) off(self.document, 'mousemove', sendMove); - off(self.document, 'mouseup', up); - return self.cancel(ev); - }); - } - - return self.cancel(ev); - }); - - //if (self.normalMouse) { - // on(self.document, 'mousemove', sendMove); - //} - - on(el, 'wheel', function(ev) { - if (!self.mouseEvents) return; - if (self.x10Mouse - || self.vt300Mouse - || self.decLocator) return; - sendButton(ev); - return self.cancel(ev); - }); - - // allow wheel scrolling in - // the shell for example - on(el, 'wheel', function(ev) { - if (self.mouseEvents) return; - self.viewport.onWheel(ev); - return self.cancel(ev); - }); - - on(el, 'touchstart', function(ev) { - if (self.mouseEvents) return; - self.viewport.onTouchStart(ev); - return self.cancel(ev); - }); - - on(el, 'touchmove', function(ev) { - if (self.mouseEvents) return; - self.viewport.onTouchMove(ev); - return self.cancel(ev); - }); -}; - -/** - * Destroys the terminal. - */ -Terminal.prototype.destroy = function() { - this.readable = false; - this.writable = false; - this._events = {}; - this.handler = function() {}; - this.write = function() {}; - if (this.element && this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - } - //this.emit('close'); -}; - -/** - * Tells the renderer to refresh terminal content between two rows (inclusive) at the next - * opportunity. - * @param {number} start The row to start from (between 0 and this.rows - 1). - * @param {number} end The row to end at (between start and this.rows - 1). - */ -Terminal.prototype.refresh = function(start, end) { - if (this.renderer) { - this.renderer.queueRefresh(start, end); - } -}; - -/** - * Queues linkification for the specified rows. - * @param {number} start The row to start from (between 0 and this.rows - 1). - * @param {number} end The row to end at (between start and this.rows - 1). - */ -Terminal.prototype.queueLinkification = function(start, end) { - if (this.linkifier) { - for (let i = start; i <= end; i++) { - this.linkifier.linkifyRow(i); - } - } -}; - -/** - * Display the cursor element - */ -Terminal.prototype.showCursor = function() { - if (!this.cursorState) { - this.cursorState = 1; - this.refresh(this.buffer.y, this.buffer.y); - } -}; - -/** - * Scroll the terminal down 1 row, creating a blank line. - * @param {boolean} isWrapped Whether the new line is wrapped from the previous - * line. - */ -Terminal.prototype.scroll = function(isWrapped) { - var row; - - // Make room for the new row in lines - if (this.buffer.lines.length === this.buffer.lines.maxLength) { - this.buffer.lines.trimStart(1); - this.buffer.ybase--; - if (this.buffer.ydisp !== 0) { - this.buffer.ydisp--; - } - } - - this.buffer.ybase++; - - // TODO: Why is this done twice? - if (!this.userScrolling) { - this.buffer.ydisp = this.buffer.ybase; - } - - // last line - row = this.buffer.ybase + this.rows - 1; - - // subtract the bottom scroll region - row -= this.rows - 1 - this.buffer.scrollBottom; - - if (row === this.buffer.lines.length) { - // Optimization: pushing is faster than splicing when they amount to the same behavior - this.buffer.lines.push(this.blankLine(undefined, isWrapped)); - } else { - // add our new line - this.buffer.lines.splice(row, 0, this.blankLine(undefined, isWrapped)); - } - - if (this.buffer.scrollTop !== 0) { - if (this.buffer.ybase !== 0) { - this.buffer.ybase--; - if (!this.userScrolling) { - this.buffer.ydisp = this.buffer.ybase; - } - } - this.buffer.lines.splice(this.buffer.ybase + this.buffer.scrollTop, 1); - } - - // this.maxRange(); - 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); -}; - -/** - * Scroll the display of the terminal - * @param {number} disp The number of lines to scroll down (negatives scroll up). - * @param {boolean} suppressScrollEvent Don't emit the scroll event as scrollDisp. This is used - * to avoid unwanted events being handled by the veiwport when the event was triggered from the - * viewport originally. - */ -Terminal.prototype.scrollDisp = function(disp, suppressScrollEvent) { - if (disp < 0) { - if (this.buffer.ydisp === 0) { - return; - } - this.userScrolling = true; - } else if (disp + this.buffer.ydisp >= this.buffer.ybase) { - this.userScrolling = false; - } - - const oldYdisp = this.buffer.ydisp; - this.buffer.ydisp = Math.max(Math.min(this.buffer.ydisp + disp, this.buffer.ybase), 0); - - // No change occurred, don't trigger scroll/refresh - if (oldYdisp === this.buffer.ydisp) { - return; - } - - if (!suppressScrollEvent) { - this.emit('scroll', this.buffer.ydisp); - } - - this.refresh(0, this.rows - 1); -}; - -/** - * Scroll the display of the terminal by a number of pages. - * @param {number} pageCount The number of pages to scroll (negative scrolls up). - */ -Terminal.prototype.scrollPages = function(pageCount) { - this.scrollDisp(pageCount * (this.rows - 1)); -}; - -/** - * Scrolls the display of the terminal to the top. - */ -Terminal.prototype.scrollToTop = function() { - this.scrollDisp(-this.buffer.ydisp); -}; - -/** - * Scrolls the display of the terminal to the bottom. - */ -Terminal.prototype.scrollToBottom = function() { - this.scrollDisp(this.buffer.ybase - this.buffer.ydisp); -}; - -/** - * Writes text to the terminal. - * @param {string} data The text to write to the terminal. - */ -Terminal.prototype.write = function(data) { - this.writeBuffer.push(data); - - // Send XOFF to pause the pty process if the write buffer becomes too large so - // xterm.js can catch up before more data is sent. This is necessary in order - // to keep signals such as ^C responsive. - if (this.options.useFlowControl && !this.xoffSentToCatchUp && this.writeBuffer.length >= WRITE_BUFFER_PAUSE_THRESHOLD) { - // XOFF - stop pty pipe - // XON will be triggered by emulator before processing data chunk - this.send(C0.DC3); - this.xoffSentToCatchUp = true; - } - - if (!this.writeInProgress && this.writeBuffer.length > 0) { - // Kick off a write which will write all data in sequence recursively - this.writeInProgress = true; - // Kick off an async innerWrite so more writes can come in while processing data - var self = this; - setTimeout(function () { - self.innerWrite(); - }); - } -}; - -Terminal.prototype.innerWrite = function() { - var writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE); - while (writeBatch.length > 0) { - var data = writeBatch.shift(); - var l = data.length, i = 0, j, cs, ch, code, low, ch_width, row; - - // If XOFF was sent in order to catch up with the pty process, resume it if - // the writeBuffer is empty to allow more data to come in. - if (this.xoffSentToCatchUp && writeBatch.length === 0 && this.writeBuffer.length === 0) { - this.send(C0.DC1); - this.xoffSentToCatchUp = false; - } - - this.refreshStart = this.buffer.y; - this.refreshEnd = this.buffer.y; - - // HACK: Set the parser state based on it's state at the time of return. - // This works around the bug #662 which saw the parser state reset in the - // middle of parsing escape sequence in two chunks. For some reason the - // state of the parser resets to 0 after exiting parser.parse. This change - // just sets the state back based on the correct return statement. - var state = this.parser.parse(data); - this.parser.setState(state); - - this.updateRange(this.buffer.y); - this.refresh(this.refreshStart, this.refreshEnd); - } - if (this.writeBuffer.length > 0) { - // Allow renderer to catch up before processing the next batch - var self = this; - setTimeout(function () { - self.innerWrite(); - }, 0); - } else { - this.writeInProgress = false; - } -}; - -/** - * Writes text to the terminal, followed by a break line character (\n). - * @param {string} data The text to write to the terminal. - */ -Terminal.prototype.writeln = function(data) { - this.write(data + '\r\n'); -}; - -/** - * DEPRECATED: only for backward compatibility. Please use attachCustomKeyEventHandler() instead. - * @param {function} customKeydownHandler The custom KeyboardEvent handler to attach. This is a - * function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent - * the default action. The function returns whether the event should be processed by xterm.js. - */ -Terminal.prototype.attachCustomKeydownHandler = function(customKeydownHandler) { - let message = 'attachCustomKeydownHandler() is DEPRECATED and will be removed soon. Please use attachCustomKeyEventHandler() instead.'; - console.warn(message); - this.attachCustomKeyEventHandler(customKeydownHandler); -}; - -/** - * Attaches a custom key event handler which is run before keys are processed, giving consumers of - * xterm.js ultimate control as to what keys should be processed by the terminal and what keys - * should not. - * @param {function} customKeyEventHandler The custom KeyboardEvent handler to attach. This is a - * function that takes a KeyboardEvent, allowing consumers to stop propogation and/or prevent - * the default action. The function returns whether the event should be processed by xterm.js. - */ -Terminal.prototype.attachCustomKeyEventHandler = function(customKeyEventHandler) { - this.customKeyEventHandler = customKeyEventHandler; -}; - -/** - * Attaches a http(s) link handler, forcing web links to behave differently to - * regular tags. This will trigger a refresh as links potentially need to be - * reconstructed. Calling this with null will remove the handler. - * @param {LinkMatcherHandler} handler The handler callback function. - */ -Terminal.prototype.setHypertextLinkHandler = function(handler) { - if (!this.linkifier) { - throw new Error('Cannot attach a hypertext link handler before Terminal.open is called'); - } - this.linkifier.setHypertextLinkHandler(handler); - // Refresh to force links to refresh - this.refresh(0, this.rows - 1); -}; - -/** - * Attaches a validation callback for hypertext links. This is useful to use - * validation logic or to do something with the link's element and url. - * @param {LinkMatcherValidationCallback} callback The callback to use, this can - * be cleared with null. - */ -Terminal.prototype.setHypertextValidationCallback = function(callback) { - if (!this.linkifier) { - throw new Error('Cannot attach a hypertext validation callback before Terminal.open is called'); - } - this.linkifier.setHypertextValidationCallback(callback); - // Refresh to force links to refresh - this.refresh(0, this.rows - 1); -}; - -/** - * Registers a link matcher, allowing custom link patterns to be matched and - * handled. - * @param {RegExp} regex The regular expression to search for, specifically - * this searches the textContent of the rows. You will want to use \s to match - * a space ' ' character for example. - * @param {LinkMatcherHandler} handler The callback when the link is called. - * @param {LinkMatcherOptions} [options] Options for the link matcher. - * @return {number} The ID of the new matcher, this can be used to deregister. - */ -Terminal.prototype.registerLinkMatcher = function(regex, handler, options) { - if (this.linkifier) { - var matcherId = this.linkifier.registerLinkMatcher(regex, handler, options); - this.refresh(0, this.rows - 1); - return matcherId; - } -}; - -/** - * Deregisters a link matcher if it has been registered. - * @param {number} matcherId The link matcher's ID (returned after register) - */ -Terminal.prototype.deregisterLinkMatcher = function(matcherId) { - if (this.linkifier) { - if (this.linkifier.deregisterLinkMatcher(matcherId)) { - this.refresh(0, this.rows - 1); - } - } -}; - -/** - * Gets whether the terminal has an active selection. - */ -Terminal.prototype.hasSelection = function() { - return this.selectionManager ? this.selectionManager.hasSelection : false; -}; - -/** - * Gets the terminal's current selection, this is useful for implementing copy - * behavior outside of xterm.js. - */ -Terminal.prototype.getSelection = function() { - return this.selectionManager ? this.selectionManager.selectionText : ''; -}; - -/** - * Clears the current terminal selection. - */ -Terminal.prototype.clearSelection = function() { - if (this.selectionManager) { - this.selectionManager.clearSelection(); - } -}; - -/** - * Selects all text within the terminal. - */ -Terminal.prototype.selectAll = function() { - if (this.selectionManager) { - this.selectionManager.selectAll(); - } -}; - -/** - * Handle a keydown event - * Key Resources: - * - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent - * @param {KeyboardEvent} ev The keydown event to be handled. - */ -Terminal.prototype.keyDown = function(ev) { - if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { - return false; - } - - this.restartCursorBlinking(); - - if (!this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { - if (this.buffer.ybase !== this.buffer.ydisp) { - this.scrollToBottom(); - } - return false; - } - - var self = this; - var result = this.evaluateKeyEscapeSequence(ev); - - if (result.key === C0.DC3) { // XOFF - this.writeStopped = true; - } else if (result.key === C0.DC1) { // XON - this.writeStopped = false; - } - - if (result.scrollDisp) { - this.scrollDisp(result.scrollDisp); - return this.cancel(ev, true); - } - - if (isThirdLevelShift(this, ev)) { - return true; - } - - if (result.cancel) { - // The event is canceled at the end already, is this necessary? - this.cancel(ev, true); - } - - if (!result.key) { - return true; - } - - this.emit('keydown', ev); - this.emit('key', result.key, ev); - this.showCursor(); - this.handler(result.key); - - return this.cancel(ev, true); -}; - -/** - * Returns an object that determines how a KeyboardEvent should be handled. The key of the - * returned value is the new key code to pass to the PTY. - * - * Reference: http://invisible-island.net/xterm/ctlseqs/ctlseqs.html - * @param {KeyboardEvent} ev The keyboard event to be translated to key escape sequence. - */ -Terminal.prototype.evaluateKeyEscapeSequence = function(ev) { - var result = { - // Whether to cancel event propogation (NOTE: this may not be needed since the event is - // canceled at the end of keyDown - cancel: false, - // The new key even to emit - key: undefined, - // The number of characters to scroll, if this is defined it will cancel the event - scrollDisp: undefined - }; - var modifiers = ev.shiftKey << 0 | ev.altKey << 1 | ev.ctrlKey << 2 | ev.metaKey << 3; - switch (ev.keyCode) { - case 8: - // backspace - if (ev.shiftKey) { - result.key = C0.BS; // ^H - break; - } - result.key = C0.DEL; // ^? - break; - case 9: - // tab - if (ev.shiftKey) { - result.key = C0.ESC + '[Z'; - break; - } - result.key = C0.HT; - result.cancel = true; - break; - case 13: - // return/enter - result.key = C0.CR; - result.cancel = true; - break; - case 27: - // escape - result.key = C0.ESC; - result.cancel = true; - break; - case 37: - // left-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'D'; - // HACK: Make Alt + left-arrow behave like Ctrl + left-arrow: move one word backwards - // http://unix.stackexchange.com/a/108106 - // macOS uses different escape sequences than linux - if (result.key == C0.ESC + '[1;3D') { - result.key = (this.browser.isMac) ? C0.ESC + 'b' : C0.ESC + '[1;5D'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OD'; - } else { - result.key = C0.ESC + '[D'; - } - break; - case 39: - // right-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'C'; - // HACK: Make Alt + right-arrow behave like Ctrl + right-arrow: move one word forward - // http://unix.stackexchange.com/a/108106 - // macOS uses different escape sequences than linux - if (result.key == C0.ESC + '[1;3C') { - result.key = (this.browser.isMac) ? C0.ESC + 'f' : C0.ESC + '[1;5C'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OC'; - } else { - result.key = C0.ESC + '[C'; - } - break; - case 38: - // up-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'A'; - // HACK: Make Alt + up-arrow behave like Ctrl + up-arrow - // http://unix.stackexchange.com/a/108106 - if (result.key == C0.ESC + '[1;3A') { - result.key = C0.ESC + '[1;5A'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OA'; - } else { - result.key = C0.ESC + '[A'; - } - break; - case 40: - // down-arrow - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'B'; - // HACK: Make Alt + down-arrow behave like Ctrl + down-arrow - // http://unix.stackexchange.com/a/108106 - if (result.key == C0.ESC + '[1;3B') { - result.key = C0.ESC + '[1;5B'; - } - } else if (this.applicationCursor) { - result.key = C0.ESC + 'OB'; - } else { - result.key = C0.ESC + '[B'; - } - break; - case 45: - // insert - if (!ev.shiftKey && !ev.ctrlKey) { - // or + are used to - // copy-paste on some systems. - result.key = C0.ESC + '[2~'; - } - break; - case 46: - // delete - if (modifiers) { - result.key = C0.ESC + '[3;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[3~'; - } - break; - case 36: - // home - if (modifiers) - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'H'; - else if (this.applicationCursor) - result.key = C0.ESC + 'OH'; - else - result.key = C0.ESC + '[H'; - break; - case 35: - // end - if (modifiers) - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'F'; - else if (this.applicationCursor) - result.key = C0.ESC + 'OF'; - else - result.key = C0.ESC + '[F'; - break; - case 33: - // page up - if (ev.shiftKey) { - result.scrollDisp = -(this.rows - 1); - } else { - result.key = C0.ESC + '[5~'; - } - break; - case 34: - // page down - if (ev.shiftKey) { - result.scrollDisp = this.rows - 1; - } else { - result.key = C0.ESC + '[6~'; - } - break; - case 112: - // F1-F12 - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'P'; - } else { - result.key = C0.ESC + 'OP'; - } - break; - case 113: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'Q'; - } else { - result.key = C0.ESC + 'OQ'; - } - break; - case 114: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'R'; - } else { - result.key = C0.ESC + 'OR'; - } - break; - case 115: - if (modifiers) { - result.key = C0.ESC + '[1;' + (modifiers + 1) + 'S'; - } else { - result.key = C0.ESC + 'OS'; - } - break; - case 116: - if (modifiers) { - result.key = C0.ESC + '[15;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[15~'; - } - break; - case 117: - if (modifiers) { - result.key = C0.ESC + '[17;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[17~'; - } - break; - case 118: - if (modifiers) { - result.key = C0.ESC + '[18;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[18~'; - } - break; - case 119: - if (modifiers) { - result.key = C0.ESC + '[19;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[19~'; - } - break; - case 120: - if (modifiers) { - result.key = C0.ESC + '[20;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[20~'; - } - break; - case 121: - if (modifiers) { - result.key = C0.ESC + '[21;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[21~'; - } - break; - case 122: - if (modifiers) { - result.key = C0.ESC + '[23;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[23~'; - } - break; - case 123: - if (modifiers) { - result.key = C0.ESC + '[24;' + (modifiers + 1) + '~'; - } else { - result.key = C0.ESC + '[24~'; - } - break; - default: - // a-z and space - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - if (ev.keyCode >= 65 && ev.keyCode <= 90) { - result.key = String.fromCharCode(ev.keyCode - 64); - } else if (ev.keyCode === 32) { - // NUL - result.key = String.fromCharCode(0); - } else if (ev.keyCode >= 51 && ev.keyCode <= 55) { - // escape, file sep, group sep, record sep, unit sep - result.key = String.fromCharCode(ev.keyCode - 51 + 27); - } else if (ev.keyCode === 56) { - // delete - result.key = String.fromCharCode(127); - } else if (ev.keyCode === 219) { - // ^[ - Control Sequence Introducer (CSI) - result.key = String.fromCharCode(27); - } else if (ev.keyCode === 220) { - // ^\ - String Terminator (ST) - result.key = String.fromCharCode(28); - } else if (ev.keyCode === 221) { - // ^] - Operating System Command (OSC) - result.key = String.fromCharCode(29); - } - } else if (!this.browser.isMac && ev.altKey && !ev.ctrlKey && !ev.metaKey) { - // On Mac this is a third level shift. Use instead. - if (ev.keyCode >= 65 && ev.keyCode <= 90) { - result.key = C0.ESC + String.fromCharCode(ev.keyCode + 32); - } else if (ev.keyCode === 192) { - result.key = C0.ESC + '`'; - } else if (ev.keyCode >= 48 && ev.keyCode <= 57) { - result.key = C0.ESC + (ev.keyCode - 48); - } - } else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) { - if (ev.keyCode === 65) { // cmd + a - this.selectAll(); - } - } - break; - } - - return result; -}; - -/** - * Set the G level of the terminal - * @param g - */ -Terminal.prototype.setgLevel = function(g) { - this.glevel = g; - this.charset = this.charsets[g]; -}; - -/** - * Set the charset for the given G level of the terminal - * @param g - * @param charset - */ -Terminal.prototype.setgCharset = function(g, charset) { - this.charsets[g] = charset; - if (this.glevel === g) { - this.charset = charset; - } -}; - -/** - * Handle a keypress event. - * Key Resources: - * - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent - * @param {KeyboardEvent} ev The keypress event to be handled. - */ -Terminal.prototype.keyPress = function(ev) { - var key; - - if (this.customKeyEventHandler && this.customKeyEventHandler(ev) === false) { - return false; - } - - this.cancel(ev); - - if (ev.charCode) { - key = ev.charCode; - } else if (ev.which == null) { - key = ev.keyCode; - } else if (ev.which !== 0 && ev.charCode !== 0) { - key = ev.which; - } else { - return false; - } - - if (!key || ( - (ev.altKey || ev.ctrlKey || ev.metaKey) && !isThirdLevelShift(this, ev) - )) { - return false; - } - - key = String.fromCharCode(key); - - this.emit('keypress', key, ev); - this.emit('key', key, ev); - this.showCursor(); - this.handler(key); - - return true; -}; - -/** - * Send data for handling to the terminal - * @param {string} data - */ -Terminal.prototype.send = function(data) { - var self = this; - - if (!this.queue) { - setTimeout(function() { - self.handler(self.queue); - self.queue = ''; - }, 1); - } - - this.queue += data; -}; - -/** - * Ring the bell. - * Note: We could do sweet things with webaudio here - */ -Terminal.prototype.bell = function() { - if (!this.visualBell) return; - var self = this; - this.element.style.borderColor = 'white'; - setTimeout(function() { - self.element.style.borderColor = ''; - }, 10); - if (this.popOnBell) this.focus(); -}; - -/** - * Log the current state to the console. - */ -Terminal.prototype.log = function() { - if (!this.debug) return; - if (!this.context.console || !this.context.console.log) return; - var args = Array.prototype.slice.call(arguments); - this.context.console.log.apply(this.context.console, args); -}; - -/** - * Log the current state as error to the console. - */ -Terminal.prototype.error = function() { - if (!this.debug) return; - if (!this.context.console || !this.context.console.error) return; - var args = Array.prototype.slice.call(arguments); - this.context.console.error.apply(this.context.console, args); -}; - -/** - * Resizes the terminal. - * - * @param {number} x The number of columns to resize to. - * @param {number} y The number of rows to resize to. - */ -Terminal.prototype.resize = function(x, y) { - if (isNaN(x) || isNaN(y)) { - return; - } - - if (y > this.getOption('scrollback')) { - this.setOption('scrollback', y) - } - - var line - , el - , i - , j - , ch - , addToY; - - if (x === this.cols && y === this.rows) { - // Check if we still need to measure the char size (fixes #785). - if (!this.charMeasure.width || !this.charMeasure.height) { - this.charMeasure.measure(); - } - return; - } - - if (x < 1) x = 1; - if (y < 1) y = 1; - - // resize cols - j = this.cols; - if (j < x) { - ch = [this.defAttr, ' ', 1]; // does xterm use the default attr? - i = this.buffer.lines.length; - while (i--) { - if (this.buffer.lines.get(i) === undefined) { - this.buffer.lines.set(i, this.blankLine()); - } - while (this.buffer.lines.get(i).length < x) { - this.buffer.lines.get(i).push(ch); - } - } - } - - this.cols = x; - this.setupStops(this.cols); - - // resize rows - j = this.rows; - addToY = 0; - if (j < y) { - el = this.element; - while (j++ < y) { - // y is rows, not this.buffer.y - if (this.buffer.lines.length < y + this.buffer.ybase) { - if (this.buffer.ybase > 0 && this.buffer.lines.length <= this.buffer.ybase + this.buffer.y + addToY + 1) { - // There is room above the buffer and there are no empty elements below the line, - // scroll up - this.buffer.ybase--; - addToY++; - if (this.buffer.ydisp > 0) { - // Viewport is at the top of the buffer, must increase downwards - this.buffer.ydisp--; - } - } else { - // Add a blank line if there is no buffer left at the top to scroll to, or if there - // are blank lines after the cursor - this.buffer.lines.push(this.blankLine()); - } - } - if (this.children.length < y) { - this.insertRow(); - } - } - } else { // (j > y) - while (j-- > y) { - if (this.buffer.lines.length > y + this.buffer.ybase) { - if (this.buffer.lines.length > this.buffer.ybase + this.buffer.y + 1) { - // The line is a blank line below the cursor, remove it - this.buffer.lines.pop(); - } else { - // The line is the cursor, scroll down - this.buffer.ybase++; - this.buffer.ydisp++; - } - } - if (this.children.length > y) { - el = this.children.shift(); - if (!el) continue; - el.parentNode.removeChild(el); - } - } - } - this.rows = y; - - // Make sure that the cursor stays on screen - if (this.buffer.y >= y) { - this.buffer.y = y - 1; - } - if (addToY) { - this.buffer.y += addToY; - } - - if (this.buffer.x >= x) { - this.buffer.x = x - 1; - } - - this.buffer.scrollTop = 0; - this.buffer.scrollBottom = y - 1; - - this.charMeasure.measure(); - - this.refresh(0, this.rows - 1); - - this.geometry = [this.cols, this.rows]; - this.emit('resize', {terminal: this, cols: x, rows: y}); -}; - -/** - * Updates the range of rows to refresh - * @param {number} y The number of rows to refresh next. - */ -Terminal.prototype.updateRange = function(y) { - if (y < this.refreshStart) this.refreshStart = y; - if (y > this.refreshEnd) this.refreshEnd = y; - // if (y > this.refreshEnd) { - // this.refreshEnd = y; - // if (y > this.rows - 1) { - // this.refreshEnd = this.rows - 1; - // } - // } -}; - -/** - * Set the range of refreshing to the maximum value - */ -Terminal.prototype.maxRange = function() { - this.refreshStart = 0; - this.refreshEnd = this.rows - 1; -}; - - - -/** - * Setup the tab stops. - * @param {number} i - */ -Terminal.prototype.setupStops = function(i) { - if (i != null) { - if (!this.buffer.tabs[i]) { - i = this.prevStop(i); - } - } else { - this.buffer.tabs = {}; - i = 0; - } - - for (; i < this.cols; i += this.getOption('tabStopWidth')) { - this.buffer.tabs[i] = true; - } -}; - - -/** - * Move the cursor to the previous tab stop from the given position (default is current). - * @param {number} x The position to move the cursor to the previous tab stop. - */ -Terminal.prototype.prevStop = function(x) { - if (x == null) x = this.buffer.x; - while (!this.buffer.tabs[--x] && x > 0); - return x >= this.cols - ? this.cols - 1 - : x < 0 ? 0 : x; -}; - - -/** - * Move the cursor one tab stop forward from the given position (default is current). - * @param {number} x The position to move the cursor one tab stop forward. - */ -Terminal.prototype.nextStop = function(x) { - if (x == null) x = this.buffer.x; - while (!this.buffer.tabs[++x] && x < this.cols); - return x >= this.cols - ? this.cols - 1 - : x < 0 ? 0 : x; -}; - - -/** - * Erase in the identified line everything from "x" to the end of the line (right). - * @param {number} x The column from which to start erasing to the end of the line. - * @param {number} y The line in which to operate. - */ -Terminal.prototype.eraseRight = function(x, y) { - var line = this.buffer.lines.get(this.buffer.ybase + y); - if (!line) { - return; - } - var ch = [this.eraseAttr(), ' ', 1]; // xterm - for (; x < this.cols; x++) { - line[x] = ch; - } - this.updateRange(y); -}; - - - -/** - * Erase in the identified line everything from "x" to the start of the line (left). - * @param {number} x The column from which to start erasing to the start of the line. - * @param {number} y The line in which to operate. - */ -Terminal.prototype.eraseLeft = function(x, y) { - var line = this.buffer.lines.get(this.buffer.ybase + y); - if (!line) { - return; - } - var ch = [this.eraseAttr(), ' ', 1]; // xterm - x++; - while (x--) { - line[x] = ch; - } - this.updateRange(y); -}; - -/** - * Clears the entire buffer, making the prompt line the new first line. - */ -Terminal.prototype.clear = function() { - if (this.buffer.ybase === 0 && this.buffer.y === 0) { - // Don't clear if it's already clear - return; - } - this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)); - this.buffer.lines.length = 1; - this.buffer.ydisp = 0; - this.buffer.ybase = 0; - this.buffer.y = 0; - for (var i = 1; i < this.rows; i++) { - this.buffer.lines.push(this.blankLine()); - } - this.refresh(0, this.rows - 1); - this.emit('scroll', this.buffer.ydisp); -}; - -/** - * Erase all content in the given line - * @param {number} y The line to erase all of its contents. - */ -Terminal.prototype.eraseLine = function(y) { - this.eraseRight(0, y); -}; - - -/** - * Return the data array of a blank line - * @param {number} cur First bunch of data for each "blank" character. - * @param {boolean} isWrapped Whether the new line is wrapped from the previous line. - */ -Terminal.prototype.blankLine = function(cur, isWrapped) { - var attr = cur - ? this.eraseAttr() - : this.defAttr; - - var ch = [attr, ' ', 1] // width defaults to 1 halfwidth character - , line = [] - , i = 0; - - // TODO: It is not ideal that this is a property on an array, a buffer line - // class should be added that will hold this data and other useful functions. - if (isWrapped) { - line.isWrapped = isWrapped; - } - - for (; i < this.cols; i++) { - line[i] = ch; - } - - return line; -}; - - -/** - * If cur return the back color xterm feature attribute. Else return defAttr. - * @param {object} cur - */ -Terminal.prototype.ch = function(cur) { - return cur - ? [this.eraseAttr(), ' ', 1] - : [this.defAttr, ' ', 1]; -}; - - -/** - * Evaluate if the current terminal is the given argument. - * @param {object} term The terminal to evaluate - */ -Terminal.prototype.is = function(term) { - var name = this.termName; - return (name + '').indexOf(term) === 0; -}; - - -/** - * Emit the 'data' event and populate the given data. - * @param {string} data The data to populate in the event. - */ -Terminal.prototype.handler = function(data) { - // Prevents all events to pty process if stdin is disabled - if (this.options.disableStdin) { - return; - } - - // Clear the selection if the selection manager is available and has an active selection - if (this.selectionManager && this.selectionManager.hasSelection) { - this.selectionManager.clearSelection(); - } - - // Input is being sent to the terminal, the terminal should focus the prompt. - if (this.buffer.ybase !== this.buffer.ydisp) { - this.scrollToBottom(); - } - this.emit('data', data); -}; - - -/** - * Emit the 'title' event and populate the given title. - * @param {string} title The title to populate in the event. - */ -Terminal.prototype.handleTitle = function(title) { - /** - * 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); -}; - - -/** - * ESC - */ - -/** - * ESC D Index (IND is 0x84). - */ -Terminal.prototype.index = function() { - this.buffer.y++; - if (this.buffer.y > this.buffer.scrollBottom) { - this.buffer.y--; - this.scroll(); - } - // If the end of the line is hit, prevent this action from wrapping around to the next line. - if (this.buffer.x >= this.cols) { - this.buffer.x--; - } -}; - - -/** - * ESC M Reverse Index (RI is 0x8d). - * - * Move the cursor up one row, inserting a new blank line if necessary. - */ -Terminal.prototype.reverseIndex = function() { - var j; - if (this.buffer.y === this.buffer.scrollTop) { - // possibly move the code below to term.reverseScroll(); - // test: echo -ne '\e[1;1H\e[44m\eM\e[0m' - // blankLine(true) is xterm/linux behavior - this.buffer.lines.shiftElements(this.buffer.y + this.buffer.ybase, this.rows - 1, 1); - this.buffer.lines.set(this.buffer.y + this.buffer.ybase, this.blankLine(true)); - this.updateRange(this.buffer.scrollTop); - this.updateRange(this.buffer.scrollBottom); - } else { - this.buffer.y--; - } -}; - - -/** - * ESC c Full Reset (RIS). - */ -Terminal.prototype.reset = function() { - this.options.rows = this.rows; - this.options.cols = this.cols; - var customKeyEventHandler = this.customKeyEventHandler; - var cursorBlinkInterval = this.cursorBlinkInterval; - var inputHandler = this.inputHandler; - var buffers = this.buffers; - Terminal.call(this, this.options); - this.customKeyEventHandler = customKeyEventHandler; - this.cursorBlinkInterval = cursorBlinkInterval; - this.inputHandler = inputHandler; - this.buffers = buffers; - this.refresh(0, this.rows - 1); - this.viewport.syncScrollArea(); -}; - - -/** - * ESC H Tab Set (HTS is 0x88). - */ -Terminal.prototype.tabSet = function() { - this.buffer.tabs[this.buffer.x] = true; -}; - -/** - * Helpers - */ - -function on(el, type, handler, capture) { - if (!Array.isArray(el)) { - el = [el]; - } - el.forEach(function (element) { - element.addEventListener(type, handler, capture || false); - }); -} - -function off(el, type, handler, capture) { - el.removeEventListener(type, handler, capture || false); -} - -function cancel(ev, force) { - if (!this.cancelEvents && !force) { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - return false; -} - -function inherits(child, parent) { - function f() { - this.constructor = child; - } - f.prototype = parent.prototype; - child.prototype = new f; -} - -function indexOf(obj, el) { - var i = obj.length; - while (i--) { - if (obj[i] === el) return i; - } - return -1; -} - -function isThirdLevelShift(term, ev) { - var thirdLevelKey = - (term.browser.isMac && ev.altKey && !ev.ctrlKey && !ev.metaKey) || - (term.browser.isMSWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); - - if (ev.type == 'keypress') { - return thirdLevelKey; - } - - // Don't invoke for arrows, pageDown, home, backspace, etc. (on non-keypress events) - return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47); -} - -// Expose to InputHandler (temporary) -Terminal.prototype.matchColor = matchColor; - -function matchColor(r1, g1, b1) { - var hash = (r1 << 16) | (g1 << 8) | b1; - - if (matchColor._cache[hash] != null) { - return matchColor._cache[hash]; - } - - var ldiff = Infinity - , li = -1 - , i = 0 - , c - , r2 - , g2 - , b2 - , diff; - - for (; i < Terminal.vcolors.length; i++) { - c = Terminal.vcolors[i]; - r2 = c[0]; - g2 = c[1]; - b2 = c[2]; - - diff = matchColor.distance(r1, g1, b1, r2, g2, b2); - - if (diff === 0) { - li = i; - break; - } - - if (diff < ldiff) { - ldiff = diff; - li = i; - } - } - - return matchColor._cache[hash] = li; -} - -matchColor._cache = {}; - -// http://stackoverflow.com/questions/1633828 -matchColor.distance = function(r1, g1, b1, r2, g2, b2) { - return Math.pow(30 * (r1 - r2), 2) - + Math.pow(59 * (g1 - g2), 2) - + Math.pow(11 * (b1 - b2), 2); -}; - -function each(obj, iter, con) { - if (obj.forEach) return obj.forEach(iter, con); - for (var i = 0; i < obj.length; i++) { - iter.call(con, obj[i], i, obj); - } -} - -function wasMondifierKeyOnlyEvent(ev) { - return ev.keyCode === 16 || // Shift - ev.keyCode === 17 || // Ctrl - ev.keyCode === 18; // Alt -} - -function keys(obj) { - if (Object.keys) return Object.keys(obj); - var key, keys = []; - for (key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - keys.push(key); - } - } - return keys; -} - -/** - * Expose - */ - -Terminal.translateBufferLineToString = translateBufferLineToString; -Terminal.EventEmitter = EventEmitter; -Terminal.inherits = inherits; - -/** - * Adds an event listener to the terminal. - * - * @param {string} event The name of the event. TODO: Document all event types - * @param {function} callback The function to call when the event is triggered. - */ -Terminal.on = on; -Terminal.off = off; -Terminal.cancel = cancel; - -module.exports = Terminal; diff --git a/src/xterm.ts b/src/xterm.ts new file mode 100644 index 00000000..49e44151 --- /dev/null +++ b/src/xterm.ts @@ -0,0 +1,18 @@ +/** + * @license MIT + */ + +import { Terminal } from './Terminal'; + +/** + * Terminal Emulation References: + * http://vt100.net/ + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * http://invisible-island.net/vttest/ + * http://www.inwap.com/pdp10/ansicode.txt + * http://linux.die.net/man/4/console_codes + * http://linux.die.net/man/7/urxvt + */ + +module.exports = Terminal; diff --git a/tslint.json b/tslint.json index d571d9c5..4d4d4aa5 100644 --- a/tslint.json +++ b/tslint.json @@ -9,10 +9,19 @@ true, "spaces" ], + "typedef": [ + true, + "call-signature", + "parameter" + ], "eofline": true, "no-eval": true, "no-internal-module": true, "no-trailing-whitespace": true, + "one-variable-per-declaration": [ + true, + true + ], "no-unsafe-finally": true, "no-var-keyword": true, "quotemark": [