/** * 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 { TerminalOptions, CustomKeyEventHandler, Charset } from './Types'; import { ITerminal, IBrowser } from './Interfaces'; // Declare for RequireJS in loadAddon declare var define: any; /** * 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. 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 = [ // 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 = (function() { let colors = 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; let c: number; for (; i < 24; i++) { c = 8 + i * 10; out(c, c, c); } 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; })(); const _colors = defaultColors.slice(); const vcolors = (function() { const out = []; 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: TerminalOptions = { 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 { public textarea: HTMLTextAreaElement; public element: HTMLElement; public rowContainer: HTMLElement; // TODO: Do we need a reference to body? There is also a body variable? // The body of the terminal 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; // TODO: This should be removed from the interface, opting for modules to pull // in Browser for themselves. public browser: IBrowser = Browser; // TODO: Options should be private, remove from interface in favor of getOption public options: TerminalOptions; 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 = 0; public cursorHidden: boolean = false; private convertEol: boolean; // TODO: This is the data queue for send, improve name and documentation private queue: string = ''; private customKeyEventHandler: CustomKeyEventHandler = null; // 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 = null; // modes private applicationKeypad: boolean = false; private applicationCursor: boolean = false; private originMode: boolean = false; private insertMode: boolean = false; private wraparoundMode: boolean = true; // defaults: xterm - true, vt100 - false // charset // The current charset private charset: Charset = null; private gcharset: number = null; private glevel: number = 0; private charsets: Charset[] = [null]; // mouse properties private decLocator; private x10Mouse; private vt200Mouse; private vt300Mouse; private normalMouse; private mouseEvents; private sendFocus; private utfMouse; private sgrMouse; private urxvtMouse; // misc public children: HTMLElement[]; private refreshStart; private refreshEnd; private savedX; private savedY; private savedCols; // stream private readable: boolean = true; private writable: boolean = true; public defAttr: number = (0 << 18) | (257 << 9) | (256 << 0); public curAttr: number = (0 << 18) | (257 << 9) | (256 << 0); public params: (string | number)[] = []; public currentParam: string | number = 0; public prefix: string = ''; public postfix: string = ''; // user input states public writeBuffer: string[] = []; private writeInProgress: boolean = 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. */ private xoffSentToCatchUp: boolean = false; /** Whether writing has been stopped as a result of XOFF */ private writeStopped: boolean = false; // leftover surrogate high from previous write invocation private surrogate_high: string = ''; // Store if user went browsing history in scrollback private userScrolling: boolean = false; private inputHandler: InputHandler; private parser: Parser; private renderer: Renderer; public selectionManager: SelectionManager; private linkifier: Linkifier; public buffers: BufferSet; public buffer: Buffer; private 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: any = {} ) { super(); let self: any = this; // TODO: Can this be removed? if (!(this instanceof Terminal)) { return new Terminal({ cols: arguments[0], rows: arguments[1], handler: arguments[2] }); } Object.keys(DEFAULT_OPTIONS).forEach(function(key) { if (options[key] == null) { options[key] = DEFAULT_OPTIONS[key]; } // TODO: We should move away from duplicate options on the Terminal object self[key] = options[key]; }); if (options.colors.length === 8) { options.colors = options.colors.concat(_colors.slice(8)); } else if (options.colors.length === 16) { options.colors = options.colors.concat(_colors.slice(16)); } else if (options.colors.length === 10) { options.colors = options.colors.slice(0, -2).concat( _colors.slice(8, -2), options.colors.slice(-2)); } else if (options.colors.length === 18) { options.colors = options.colors.concat( _colors.slice(16, -2), options.colors.slice(-2)); } this.options = options; this.colors = options.colors; // this.context = options.context || window; // this.document = options.document || document; // TODO: WHy not document.body? this.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.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(); // 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; }); let 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(); } /** * back_color_erase feature for xterm. */ public eraseAttr(): any { // 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: string): 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: string, value: any) { // TODO: Give value a better type (boolean | string, ...) 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 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; } } private restartCursorBlinking() { this.setCursorBlinking(this.options.cursorBlink); } private setCursorBlinking(enabled) { 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() { 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() { 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() { return this.textarea.blur(); } /** * Binds the desired blur behavior on a given terminal object. */ private bindBlur() { 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() { 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() { const self = this; on(this.element, 'keydown', function (ev) { if (document.activeElement !== this) { return; } self.keyDown(ev); }, true); on(this.element, 'keypress', function (ev) { if (document.activeElement !== this) { return; } self.keyPress(ev); }, true); on(this.element, 'keyup', (ev) => { if (!wasMondifierKeyOnlyEvent(ev)) { this.focus(); } }, true); on(this.textarea, 'keydown', (ev) => { this.keyDown(ev); }, true); on(this.textarea, 'keypress', (ev) => { 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) { 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, focus) { 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.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-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, 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. */ public updateCharSizeStyles() { 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() { 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) { 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) { let button = pressed; let 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; 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 + ';' + (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 = []; encode(data, button); encode(data, pos.x); encode(data, pos.y); self.send(C0.ESC + '[M' + String.fromCharCode.apply(String, data)); } function getButton(ev) { let 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 (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) => { // 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); if (this.normalMouse) off(this.document, 'mousemove', sendMove); off(this.document, 'mouseup', handler); return this.cancel(ev); }; on(this.document, 'mouseup', handler); } return this.cancel(ev); }); // if (this.normalMouse) { // on(this.document, 'mousemove', sendMove); // } on(el, 'wheel', (ev) => { 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 = 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). */ 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() { 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() { 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): 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 {LinkMatcherHandler} handler The handler callback function. */ public setHypertextLinkHandler(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. */ public setHypertextValidationCallback(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. */ public registerLinkMatcher(regex, handler, options) { 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 {number} matcherId The link matcher's ID (returned after register) */ public deregisterLinkMatcher(matcherId) { if (this.linkifier) { if (this.linkifier.deregisterLinkMatcher(matcherId)) { this.refresh(0, this.rows - 1); } } } /** * Gets whether the terminal has an active selection. */ public hasSelection() { 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() { return this.selectionManager ? this.selectionManager.selectionText : ''; } /** * Clears the current terminal selection. */ public clearSelection() { if (this.selectionManager) { this.selectionManager.clearSelection(); } } /** * Selects all text within the terminal. */ public selectAll() { 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) { 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(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. */ private evaluateKeyEscapeSequence(ev) { const 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 }; const 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 */ 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) { 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(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 */ private send(data) { if (!this.queue) { setTimeout(() => { this.handler(this.queue); this.queue = ''; }, 1); } this.queue += data; } /** * Ring the bell. * Note: We could do sweet things with webaudio here */ public bell() { 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(): void { if (!this.options.debug) return; if (!this.context.console || !this.context.console.log) return; const args = Array.prototype.slice.call(arguments); this.context.console.log.apply(this.context.console, args); } /** * Log the current state as error to the console. */ public error(): void { if (!this.options.debug) return; if (!this.context.console || !this.context.console.error) return; const 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. */ public resize(x: number, y: number) { if (isNaN(x) || isNaN(y)) { return; } if (y > this.getOption('scrollback')) { this.setOption('scrollback', y); } let 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. */ public updateRange(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 */ public maxRange() { this.refreshStart = 0; this.refreshEnd = this.rows - 1; } /** * Setup the tab stops. * @param {number} i */ public setupStops(i?: number) { 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) { 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) { 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: [number, string, number] = [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) { const line = this.buffer.lines.get(this.buffer.ybase + y); if (!line) { return; } const ch: [number, string, number] = [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() { 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): 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?, isWrapped?: boolean) { const attr = cur ? this.eraseAttr() : this.defAttr; const ch = [attr, ' ', 1]; // width defaults to 1 halfwidth character const line = []; // 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 {object} cur */ public ch(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 */ private is(term) { // TODO: Do we need this? const name = this.options.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. */ 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) { /** * 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() { 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() { 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() { 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; 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). */ private tabSet() { this.buffer.tabs[this.buffer.x] = true; } public cancel(ev: Event, force?: 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, g1, b1) { const hash = (r1 << 16) | (g1 << 8) | b1; if (matchColorCache[hash] != null) { return matchColorCache[hash]; } let ldiff = Infinity; let li = -1; let i = 0; let c; let r2; let g2; let b2; let diff; 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; } } // TODO: Make sure things work after this is removed // each(keys(Terminal.defaults), function(key) { // Terminal[key] = Terminal.defaults[key]; // Terminal.options[key] = Terminal.defaults[key]; // }); /** * Helpers */ function globalOn(el: any, type: string, handler: (event: Event) => any, capture?: boolean) { if (!Array.isArray(el)) { el = [el]; } el.forEach(function (element) { element.addEventListener(type, handler, capture || false); }); } // TODO: Remove once everything is typed const on = globalOn; function off(el, type, handler, capture: boolean = false) { el.removeEventListener(type, handler, capture); } function inherits(child, parent) { function f() { this.constructor = child; } f.prototype = parent.prototype; child.prototype = new f; } function indexOf(obj, el) { let i = obj.length; while (i--) { if (obj[i] === el) return i; } return -1; } function isThirdLevelShift(term, ev) { const 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); } const matchColorCache = {}; // http://stackoverflow.com/questions/1633828 const matchColorDistance = 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 (let 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); let 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; // module.exports = Terminal;