UNPKG

terminal.js

Version:

terminal emulation library for javascript.

966 lines (875 loc) 24.5 kB
"use strict"; var myUtil = require("./util.js"); var inherits = require("util").inherits; var getWidth = myUtil.getWidth; var indexOfWidth = myUtil.indexOfWidth; var substrWidth = myUtil.substrWidth; /** * map of graphical character aliases * @enum * @private */ var graphics = { "`": "\u25C6", "a": "\u2592", "b": "\u2409", "c": "\u240C", "d": "\u240D", "e": "\u240A", "f": "\u00B0", "g": "\u00B1", "h": "\u2424", "i": "\u240B", "j": "\u2518", "k": "\u2510", "l": "\u250C", "m": "\u2514", "n": "\u253C", "o": "\u23BA", "p": "\u23BB", "q": "\u2500", "r": "\u23BC", "s": "\u23BD", "t": "\u251C", "u": "\u2524", "v": "\u2534", "w": "\u252C", "x": "\u2502", "y": "\u2264", "z": "\u2265", "{": "\u03C0", "|": "\u2260", "}": "\u00A3", "~": "\u00B7" }; /** * Creates setter for a specific property used on attributes, modes, and meta * properties. * * If the object defines a property _fooUsed, copy on write is enabled. This * makes sure that the object is is copied before any changes are made. * This makes it possible to reference the object from other context without * copying it every time. This reduces memory consumption in some cases. * * @private */ function setterFor(objName) { return function(name, value) { if(this["_"+objName+"sUsed"] === true) { this["_"+objName+"s"] = myUtil.extend({}, this["_"+objName+"s"]); this["_"+objName+"sUsed"] = false; } var obj = this["_"+objName+"s"]; if(!(name in obj)) throw new Error("Unknown "+objName+" `"+name+"`"); this.emit(objName+"change", name, value, obj[name]); obj[name] = value; }; } /** * A class which holds the terminals state and content * @param {object} options - object to configure the terminal * @param {number} [options.columns=80] - number of columns in the terminal * @param {number} [options.rows=24] - number of rows in the terminal * @param {object} [options.attributes] - initial attributes of the terminal * @param {string} [options.attributes.fg=null] initial foreground color * @param {string} [options.attributes.bg=null] initial background color * @param {boolean} [options.attributes.bold=false] terminal is bold by default * @param {boolean} [options.attributes.underline=false] terminal is underlined by default * @param {boolean} [options.attributes.italic=false] terminal is italic by default * @param {boolean} [options.attributes.blink=false] terminal blinks by default * @param {boolean} [options.attributes.inverse=false] terminal has reverse colors by default * @constructor */ function TermState(options) { TermState.super_.call(this, { decodeStrings: false }); options = myUtil.extend({ attributes: {}, }, options); this._defaultAttr = myUtil.extend({ fg: null, bg: null, bold: false, underline: false, italic: false, blink: false, inverse: false }, options.attributes); // This is used for copy on write. this._attributesUsed = true; this.rows = ~~options.rows || 24; this.columns = ~~options.columns || 80; this .on("newListener", this._newListener) .on("removeListener", this._removeListener) .on("pipe", this._pipe); // Reset all on first use this.reset(); } inherits(TermState, require("stream").Writable); module.exports = TermState; /** * emits resize on the reader of this class * @param {ReadableStream} a Readable Stream * @private */ TermState.prototype._pipe = function(src) { var onresize = src.emit.bind(src, "resize"); this.on("resize", onresize) .on("unpipe", function(src) { src.removeListener("resize", onresize); }); }; /** * tells a new listener the terminals state when it is registered * @param {string} ev - event name * @param {function} cb - the listening function * @private */ TermState.prototype._newListener = function(ev, cb) { var i; switch(ev) { case "lineinsert": for(i = 0; i < this.getBufferRowCount(); i++) cb.call(this, i, this.getLine(i)); break; case "resize": cb.call(this, { columns: this.columns, rows: this.rows }); break; case "cursormove": cb.call(this, this.cursor.x, this.cursor.y); break; } }; /** * cleans up listener when it is removed from the terminal state * @param {string} ev - event name * @param {function} cb - the listening function * @private */ TermState.prototype._removeListener = function(ev, cb) { var i; if(ev === "lineremove") { for(i = 0; i < this.getBufferRowCount(); i++) cb.call(this, 0, this.getLine(i)); } }; /** * resets the terminals state. */ TermState.prototype.reset = function() { if(this._buffer) this._removeLine(0, this.getBufferRowCount()); this._buffer = this._defBuffer = { str: [], attr: [] }; this._altBuffer = { str: [], attr: [] }; this._scrollback = { str: [], attr: [] }; this._modes = { cursor: true, cursorBlink: false, appKeypad: false, wrap: true, insert: false, crlf: false, mousebtn: false, mousemtn: false, mousesgr: false, reverse: false, graphic: false, stringWidth: "length" }; this._charsets = { "G0": "unicode", "G1": "unicode", "G2": "unicode", "G3": "unicode" }; this._mappedCharset = "G0"; this._mappedCharsetNext = "G0"; this._metas = { title: "", icon: "" }; this.resetAttribute(); this.cursor = {x:0,y:0}; this._savedCursor = {x:0,y:0}; this._scrollRegion = [0, this.rows-1]; this.resetLeds(); this._tabs = []; }; /** * creates a new line in the buffer * @param [line] - build line upon this value * @private */ TermState.prototype._createLine = function(line) { if(line === undefined || typeof line !== "object") { line = line ? line.toString() : ""; line = { str: line, attr: {0: this._defaultAttr} }; } else if(!line || typeof line.str !== "string" || typeof line.attr !== "object") throw new Error("line objects must contain attr and str" + line); for(var i in line.attr) { if(isNaN(i)) continue; if(i > line.str.length || line.attr[i] === undefined) delete line.attr[i]; } return line; }; /** * Takes a chunk of data and puts it in the buffer * @alias TermState.prototype.write * @see http://nodejs.org/docs/latest/api/stream.html#stream_writable_write_chunk_encoding_callback */ TermState.prototype._write = function(chunk, encoding, callback) { var i, j, line; var lines = chunk.split("\n"); var stringWidth = this._modes.stringWidth; var wrapped = false; var c = this.cursor; var splitWidth; var lastChar; for(i = 0; i < lines.length; i++) { wrapped = false; // Handle long lines if(getWidth(stringWidth, lines[i]) > this.columns - c.x) { if(this._modes.wrap) { splitWidth = indexOfWidth(stringWidth, lines[i], this.columns - c.x); lines.splice(i, 1, substrWidth(stringWidth, lines[i], 0, this.columns - c.x), lines[i].substr(splitWidth) ); wrapped = true; } else { lastChar = lines[i].substr(-1); if(c.x >= this.columns) c.x = this.columns - getWidth(stringWidth, lastChar); lines[i] = substrWidth(stringWidth, lines[i], 0, this.columns - c.x - getWidth(stringWidth, lastChar)) + lastChar; } } // write line this._lineInject(lines[i], wrapped); if(i + 1 !== lines.length) { c.y++; if(this._modes.crlf || wrapped) c.x = 0; if(c.y > this._scrollRegion[1]) { c.y--; this._removeLine(this._scrollRegion[0]); this._insertLine(this._scrollRegion[1]); } } } this.setCursor(); return callback(); }; /** * invokes the specified charset (G0, G1, G2, G3) for use, * either permanently or only on the next character */ TermState.prototype.mapCharset = function(target, nextOnly) { this._mappedCharset = target; if (!nextOnly) this._mappedCharsetNext = target; this._modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility }; /** * designates (populates) the specified charset (G0, G1, G2, G3) graphics. * only "graphics" and "unicode" supported */ TermState.prototype.selectCharset = function(charset, target) { if (!target) target = this._mappedCharset; this._charsets[target] = charset; this._modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility }; /** * converts graphics from ascii to utf8 characters when the * active character set has graphics selected * @private */ TermState.prototype._graphConvert = function(content) { // optimization for 99% of the time if(this._mappedCharset === this._mappedCharsetNext && !this._modes.graphic) { return content; } var result = "", i; for(i = 0; i < content.length; i++) { result += (this._modes.graphic && content[i] in graphics) ? graphics[content[i]] : content[i]; this._mappedCharset = this._mappedCharsetNext; this._modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility } return result; }; /** * injects a single line into the buffer. * @see _write * @private */ TermState.prototype._lineInject = function(content, wrapped) { var c = this.cursor; var line = this.getLine(); var stringWidth = this._modes.stringWidth; var args; var splitSubstr; if(this._modes.insert) { args = new Array(content.length); args.unshift(line.attr, line.str.length+1, c.x, 0); myUtil.objSplice.apply(0, args); line.str = substrWidth(stringWidth, line.str, 0, c.x) + myUtil.repeat(" ", c.x - getWidth(stringWidth, line.str)) + this._graphConvert(content) + substrWidth(stringWidth, line.str, c.x); line.str = substrWidth(stringWidth, line.str, 0, this.columns); } else { line.str = substrWidth(stringWidth, line.str, 0, c.x) + myUtil.repeat(" ", c.x - getWidth(stringWidth, line.str)) + this._graphConvert(content) + substrWidth(stringWidth, line.str, c.x + getWidth(stringWidth, content)); } this._applyAttributes(line, c.x, content.length); if(wrapped) line.attr.wrapped = true; this.setLine(c.y, line); c.x += getWidth(stringWidth, content); }; /** * removes characters at cursor position. * @params {number} count - number of characters to be removed */ TermState.prototype.removeChar = function(count) { var c = this.cursor, line = this.getLine(c.y); var last = line.attr[line.str.length]; myUtil.objSplice(line.attr, line.str.length+1, c.x, count); line.str = line.str.substr(0, c.x) + line.str.substr(c.x+count); line.attr[line.str.length] = last; this.setLine(c.y, line); }; /** * inserts whitespaces at cursor position * @params {number} count - number of whitespaces to be inserted */ TermState.prototype.insertBlank = function(count) { var c = this.cursor, line = this.getLine(c.y); var last = line.attr[line.str.length]; // TODO: unify this into one objSplice call. myUtil.objSplice(line.attr, line.str.length+1, c.x, 0, new Array(count)); myUtil.objSplice(line.attr, line.str.length+1, this.columns); line.str = line.str.substr(0, c.x) + myUtil.repeat(" ", count) + line.str.substr(c.x); line.str = line.str.substr(0, this.columns); line.attr[line.str.length] = last; this.setLine(c.y, line); }; /** * removes lines at cursor position. * @params {number} count - number of lines to be removed */ TermState.prototype.removeLine = function(count) { this._removeLine(this.cursor.y, +count); if(this._scrollRegion[1] !== this.rows-1 && this.cursor.y <= this._scrollRegion[1]) this._insertLine(this._scrollRegion[1] + 1 - count, +count); }; /** * removes lines at given position * @params {number} line - line number to start removing * @params {number} count - number of lines to be removed * @private */ TermState.prototype._removeLine = function(line, count) { var i, str, attr; if(count === undefined) count = 1; str = this._buffer.str.splice(line, count); this._scrollback.str.push.call(this._scrollback, str); attr = this._buffer.attr.splice(line, count); this._scrollback.attr.push.call(this._scrollback, attr); for(i = 0; i < str.length; i++) this.emit("lineremove", line, {str: str[i], attr: attr[i] }); return count; }; /** * sets the line to a value and emits "linechanged" event * @params {number} nbr - line number to set * @params {object} line - line content to set */ TermState.prototype.setLine = function(nbr, line) { if(typeof nbr === "object" && line === undefined) { line = nbr; nbr = this.cursor.y; } line = this._createLine(line); if(this._buffer.str.length <= nbr) { this._insertLine(nbr, line); } else { if(line.str.length > this.columns) line.attr[this.columns] = line.attr[line.str.length]; this._buffer.str[nbr] = line.str.substr(0, this.columns); this._buffer.attr[nbr] = line.attr; this.emit("linechange", nbr, line); } }; /** * inserts lines at cursor position * @params {number} count - number of lines to insert */ TermState.prototype.insertLine = function(count) { this._insertLine(this.cursor.y, +count); }; /** * inserts lines at given position * @params {number} line - line number to start inserting * @params {number} count - number of lines to be inserted * @private */ TermState.prototype._insertLine = function(nbr, line) { var h = this.getBufferRowCount(); var start = Math.min(h, nbr); var end = nbr + 1; var i; if(typeof line === "number") { end = nbr + line; line = undefined; } for(i = start; i < end; i++) { if(this.rows === this.getBufferRowCount()) this._removeLine(this._scrollRegion[1], 1); line = this._createLine(line); this._buffer.str.splice(start, 0, line.str); this._buffer.attr.splice(start, 0, line.attr); this.emit("lineinsert", start, line); line = undefined; } }; /** * TODO * @private */ TermState.prototype._applyAttributes = function(line, index, len) { var li, last, pre, pi, i; for(li = index+len; li > 0 && line.attr[li] === undefined; li--); last = line.attr[li]; for(pi = index-1; pi > 0 && line.attr[pi] === undefined; pi--); pre = line.attr[pi]; for(i = index; i < index+len; i++) delete line.attr[i]; if(pre !== this._attributes || index === line.str.length) line.attr[index] = this._attributes; if(index + len <= this.columns) line.attr[index + len] = last; this._attributesUsed = true; return this; }; /** * sets cursor to a specific position * @param {number} x - column of cursor starting at 0 * @param {number} y - row of cursor starting at 0 */ TermState.prototype.setCursor = function(x, y) { var c = this.cursor, line; if(typeof x !== "number") x = c.x; if(typeof y !== "number") y = c.y; if(x < 0) x = 0; else if(x > this.columns) x = this.columns; if(y < 0) y = 0; else if(y >= this.rows) y = this.rows - 1; if(c.x !== x || c.y !== y || arguments.length === 0) { c.x = x; c.y = y; this.emit("cursormove", x, y); } return this; }; /** * resizes terminal to a specific dimension * @param {object} size - new size of the terminal */ TermState.prototype.resize = function(size) { var c = this.cursor; // Total number of lines e need to remove in order to resize the terminal var totalLinesToRemove = Math.max(0, this.rows - size.rows); // Number of blank lines from the cursor to the bottom edge of the window var blankLines = this.rows - c.y - 1; // The number of lines we will remove from the bottom part of the terminal var removeFromBottom = Math.min(totalLinesToRemove, blankLines); // The number of lines we will remove from the top part of the terminal var removeFromTop = totalLinesToRemove - removeFromBottom; // Remove bottom lines this._removeLine(this.rows - removeFromBottom, removeFromBottom); // Remove top lines this._removeLine(0, removeFromTop); this.rows = ~~size.rows; this.columns = ~~size.columns; this.redraw(); this.setScrollRegion(0, this.rows-1); this.emit("resize", {rows: this.rows, columns: this.columns}); this.setCursor(); return this; }; TermState.prototype.redraw = function(){ for(var i = 0; i < this._buffer.str.length; i++) this.setLine(i, this.getLine(i)); }; /** * moves cursor relative * @param {number} x - relative horizontal movement * @param {number} y - relative vertical movement */ TermState.prototype.mvCursor = function(x, y) { if(x || y) this.setCursor(this.cursor.x + x, this.cursor.y + y); return this; }; /** * scrolls the scroll area of a buffer * @param {number} scroll - number of lines to be scrolled (positive: up; negative: down) */ TermState.prototype.scroll = function(scroll) { var i; var count = Math.min(Math.abs(scroll), this._scrollRegion[1] - this._scrollRegion[0]); if(scroll > 0) { this._removeLine(this._scrollRegion[0], count); for(i = 0; i < count; i++) { this._insertLine(this._scrollRegion[1] +1 - count); } } else { this._removeLine(this._scrollRegion[1] +1 -count, count); for(i = 0; i < count; i++) { this._insertLine(this._scrollRegion[0]); } } }; /** * returns plain text representation of the buffer */ TermState.prototype.toString = function() { return this._buffer.str.join("\n"); }; /** * moves cursor to previous line or scrolls up if at top */ TermState.prototype.prevLine = function() { if(this.cursor.y === this._scrollRegion[0]) this.scroll(-1); else this.mvCursor(0, -1); return this; }; /** * moves cursor to next line or scrolls down if at bottom */ TermState.prototype.nextLine = function() { if(this.cursor.y === this._scrollRegion[1]) this.scroll(1); else this.mvCursor(0, 1); return this; }; /** * resets the attributes */ TermState.prototype.resetAttribute = function(name) { if(name) this.setAttribute(name, this._defaultAttr[name]); else { this._attributesUsed = true; this._attributes = this._defaultAttr; } return this; }; /** * saves cursor position */ TermState.prototype.saveCursor = function() { this._savedCursor.x = this.cursor.x; this._savedCursor.y = this.cursor.y; return this; }; /** * restore previously saved cursor position */ TermState.prototype.restoreCursor = function() { return this.setCursor(this._savedCursor.x, this._savedCursor.y); }; /** * truncate characters from buffer at cursor position. * @param {number} count number of characters to truncate */ TermState.prototype.eraseCharacters = function(count) { var c = this.cursor, line = this.getLine(c.y); line.str = line.str.substr(0, c.x) + myUtil.repeat(" ", count) + line.str.substr(c.x + count); line.str = line.str.substr(0, this.columns); this._applyAttributes(line, c.x, count); this.setLine(c.y, line); }; /** * cleans lines * @param n can be one of the following: * <ul> * <li>0 or "after": cleans below and after cursor</li> * <li>1 or "before": cleans above and before cursor</li> * <li>2 or "all": cleans entire screen</li> * </ul> */ TermState.prototype.eraseInDisplay = function(n) { var c = this.cursor, i, line, self = this; var chLine = function() { line = self._createLine(); self._applyAttributes(line, 0, self.columns); self.setLine(i, line); }; switch(n || 0) { case "below": case "after": case 0: n = 0; for(i = c.y+1; i < this.rows; i++) chLine(); break; case "above": case "before": case 1: n = 1; for(i = 0; i < c.y-1; i++) chLine(); break; case "all": case 2: for(i = 0; i < this.rows; i++) chLine(); return this; } return this.eraseInLine(n); }; /** * cleans one line * @param n can be one of the following: * <ul> * <li>0 or "after": cleans from the cursor to the end of the line</li> * <li>1 or "before": cleans from the start of the line to the cursor</li> * <li>2 or "all": cleans entire screen</li> * </ul> */ TermState.prototype.eraseInLine = function(n) { var c = this.cursor; var line = this.getLine(); switch(n || 0) { case "after": case 0: line.str = line.str.substr(0, c.x); this._applyAttributes(line, c.x, this.columns); break; case "before": case 1: line.str = myUtil.repeat(" ",c.x) + line.str.substr(c.x); this._applyAttributes(line, 0, c.x); break; case "all": case 2: line = this._createLine(); break; } this.setLine(c.y, line); return this; }; /** * sets scroll region */ TermState.prototype.setScrollRegion = function(n, m) { this._scrollRegion[0] = +n; this._scrollRegion[1] = +m; return this; }; /** * switches between default and alternative buffer * @param alt {boolean} true for switch to alternative buffer, false for default * buffer */ TermState.prototype.switchBuffer = function(alt) { var i; var active, inactive; if(alt) { active = this._altBuffer; inactive = this._defBuffer; } else { active = this._defBuffer; inactive = this._altBuffer; } if(active === this._buffer) return; for(i = active.str.length; i < inactive.str.length; i++) this.emit("lineremove", active.str.length, this.getLine(i)); this._buffer = active; for(i = 0; i < active.str.length && i < inactive.str.length; i++) this.emit("linechange", active.str.length, this.getLine(i)); for(; i < active.str.length; i++) this.emit("lineinsert", i, this.getLine(i)); return this; }; /** * enables a LED * @param led {number} LED 0 - 3 */ TermState.prototype.ledOn = function(led) { this.setLed(led, true); return this; }; /** * enables a LED * @param led {number} LED 0 - 3 * @param value {boolean} sets LED to value */ TermState.prototype.setLed = function(led, value) { if (led < this._leds.length) { // we only have 4 leds (0,1,2,3) this._leds[led] = !!value; this.emit("ledchange", Array.apply(null, this._leds)); } return this; }; /** * disables all LEDs */ TermState.prototype.resetLeds = function() { this._leds = [!!0,!!0,!!0,!!0]; this.emit("ledchange", Array.apply(null, this._leds)); return this; }; /** * gets the internal buffer row count. Will be lesser equal than actual number of * rows */ TermState.prototype.getBufferRowCount = function() { return this._buffer.str.length; }; /** * gets the current value of an LED * @param led {number} LED 0 - 3 * @returns true if LED is enabled, false otherwise */ TermState.prototype.getLed = function(n) { return this._leds[n]; }; /** * gets the line definition * @param n {number} - line number starting at 0 * @returns line definition */ TermState.prototype.getLine = function(n) { if(n === undefined) n = this.cursor.y; if(this._buffer.str[n] !== undefined) return { str: this._buffer.str[n], attr: this._buffer.attr[n] }; else return this._createLine(); }; /** * returns the current value of a given mode * @param n {string} - mode */ TermState.prototype.getMode = function(n) { return this._modes[n]; }; /** * moves Cursor forward or backward a specified amount of tabs * @param n {number} - number of tabs to move. <0 moves backward, >0 moves * forward */ TermState.prototype.mvTab = function(n) { var x = this.cursor.x; var tabMax = this._tabs[this._tabs.length - 1] || 0; var positive = n > 0; n = Math.abs(n); while(n !== 0 && x >= 0 && x < this.columns-1) { x += positive ? 1 : -1; if(~myUtil.indexOf(this._tabs, x) || (x > tabMax && x % 8 === 0)) n--; } this.setCursor(x); }; /** * set tab at specified position * @param pos {number} - position to set a tab at */ TermState.prototype.setTab = function(pos) { // Set the default to current cursor if no tab position is specified if(pos === undefined) { pos = this.cursor.x; } // Only add the tab position if it is not there already if (!~myUtil.indexOf(this._tabs, pos)) { this._tabs.push(pos); this._tabs.sort(); } }; /** * remove a tab * @param pos {number} - position to remove a tab. Do nothing if the tab isn't * set at this position */ TermState.prototype.removeTab = function(pos) { var i, tabs = this._tabs; for(i = 0; i < tabs.length && tabs[i] !== pos; i++); tabs.splice(i, 1); }; /** * removes a tab at a given index * @params n {number} - can be one of the following * <ul> * <li>"current" or 0: searches tab at current position. no tab is at current * position delete the next tab</li> * <li>"all" or 3: deletes all tabs</li> */ TermState.prototype.tabClear = function(n) { switch(n || "current") { case "current": case 0: for(var i = this._tabs.length - 1; i >= 0; i--) { if(this._tabs[i] < this.cursor.x) { this._tabs.splice(i, 1); break; } } break; case "all": case 3: this._tabs = []; break; } }; /** * sets a given Attribute */ TermState.prototype.setAttribute = setterFor("attribute"); /** * sets a given Mode */ TermState.prototype.setMode = setterFor("mode"); /** * sets a given Meta date */ TermState.prototype.setMeta = setterFor("meta");