540 lines
14 KiB
JavaScript
540 lines
14 KiB
JavaScript
|
/**
|
||
|
* Implements a terminal emulator. We use terminal.js for the
|
||
|
* dirty work of parsing the escape sequences, but implement our
|
||
|
* own TermState, with some quirks when compared to a standard
|
||
|
* terminal emulator:
|
||
|
*
|
||
|
* - Lines and characters are created on demand. The terminal
|
||
|
* starts out with no content. The reason being, you don't want
|
||
|
* empty lines to be immediately pushed to your Telegram chat
|
||
|
* after starting a command.
|
||
|
*
|
||
|
* - Allows lines of length higher than the column size. The extra
|
||
|
* characters are appended but the cursor keeps right at the edge.
|
||
|
* Telegram already wraps long lines, having them wrapped by the
|
||
|
* terminal would be ugly.
|
||
|
*
|
||
|
* - Graphic attributes not implemented for now (would not be used
|
||
|
* for Telegram anyways).
|
||
|
*
|
||
|
* - Doesn't have an alternate buffer for now (wouldn't make much
|
||
|
* sense for Telegram rendering...) FIXME
|
||
|
*
|
||
|
* The terminal emulator emits events when lines get inserted, changed,
|
||
|
* removed or go out of view, similar to the original TermState.
|
||
|
**/
|
||
|
|
||
|
var util = require("util");
|
||
|
var Terminal = require("terminal.js");
|
||
|
|
||
|
//FIXME: investigate using a patched palette for better support on android
|
||
|
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'
|
||
|
};
|
||
|
|
||
|
|
||
|
/** INITIALIZATION & ACCESSORS **/
|
||
|
|
||
|
function TermState(options) {
|
||
|
if (!options) options = {};
|
||
|
this.rows = options.rows || 24;
|
||
|
this.columns = options.columns || 80;
|
||
|
|
||
|
this.defaultAttributes = {
|
||
|
fg: null,
|
||
|
bg: null,
|
||
|
bold: false,
|
||
|
underline: false,
|
||
|
italic: false,
|
||
|
blink: false,
|
||
|
inverse: false,
|
||
|
};
|
||
|
this.reset();
|
||
|
}
|
||
|
util.inherits(TermState, require("events").EventEmitter);
|
||
|
|
||
|
TermState.prototype.reset = function reset() {
|
||
|
this.lines = [];
|
||
|
this.cursor = [0,0];
|
||
|
this.savedCursor = [0,0];
|
||
|
|
||
|
this.modes = {
|
||
|
cursor: true,
|
||
|
cursorBlink: false,
|
||
|
appKeypad: false,
|
||
|
wrap: true,
|
||
|
insert: false,
|
||
|
crlf: false,
|
||
|
mousebtn: false,
|
||
|
mousemtn: false,
|
||
|
reverse: false,
|
||
|
graphic: false,
|
||
|
mousesgr: false,
|
||
|
};
|
||
|
this.attributes = Object.create(this.defaultAttributes);
|
||
|
this._charsets = {
|
||
|
"G0": "unicode",
|
||
|
"G1": "unicode",
|
||
|
"G2": "unicode",
|
||
|
"G3": "unicode",
|
||
|
};
|
||
|
this._mappedCharset = "G0";
|
||
|
this._mappedCharsetNext = "G0";
|
||
|
this.metas = {
|
||
|
title: '',
|
||
|
icon: ''
|
||
|
};
|
||
|
this.leds = {};
|
||
|
this._tabs = [];
|
||
|
this.emit("reset");
|
||
|
};
|
||
|
|
||
|
function getGenericSetter(field) {
|
||
|
return function genericSetter(name, value) {
|
||
|
this[field + "s"][name] = value;
|
||
|
this.emit(field, name);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
TermState.prototype.setMode = getGenericSetter("mode");
|
||
|
TermState.prototype.setMeta = getGenericSetter("meta");
|
||
|
TermState.prototype.setAttribute = getGenericSetter("attribute");
|
||
|
TermState.prototype.setLed = getGenericSetter("led");
|
||
|
|
||
|
TermState.prototype.getMode = function getMode(mode) {
|
||
|
return this.modes[mode];
|
||
|
};
|
||
|
|
||
|
TermState.prototype.getLed = function getLed(led) {
|
||
|
return !!this.leds[led];
|
||
|
};
|
||
|
|
||
|
TermState.prototype.ledOn = function ledOn(led) {
|
||
|
this.setLed(led, true);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.resetLeds = function resetLeds() {
|
||
|
this.leds = {};
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.resetAttribute = function resetAttribute(name) {
|
||
|
this.attributes[name] = this.defaultAttributes[name];
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.mapCharset = function(target, nextOnly) {
|
||
|
this._mappedCharset = target;
|
||
|
if (!nextOnly) this._mappedCharsetNext = target;
|
||
|
this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
|
||
|
};
|
||
|
|
||
|
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
|
||
|
};
|
||
|
|
||
|
|
||
|
/** CORE METHODS **/
|
||
|
|
||
|
/* Move the cursor */
|
||
|
TermState.prototype.setCursor = function setCursor(x, y) {
|
||
|
if (typeof x === 'number')
|
||
|
this.cursor[0] = x;
|
||
|
|
||
|
if (typeof y === 'number')
|
||
|
this.cursor[1] = y;
|
||
|
|
||
|
this.cursor = this.getCursor();
|
||
|
this.emit("cursor");
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
/* Get the real cursor position (the logical one may be off-bounds) */
|
||
|
TermState.prototype.getCursor = function getCursor() {
|
||
|
var x = this.cursor[0], y = this.cursor[1];
|
||
|
|
||
|
if (x >= this.columns) x = this.columns - 1;
|
||
|
else if (x < 0) x = 0;
|
||
|
|
||
|
if (y >= this.rows) y = this.rows - 1;
|
||
|
else if (y < 0) y = 0;
|
||
|
|
||
|
return [x,y];
|
||
|
};
|
||
|
|
||
|
/* Get the line at specified position, allocating it if necessary */
|
||
|
TermState.prototype.getLine = function getLine(y) {
|
||
|
if (typeof y !== "number") y = this.getCursor()[1];
|
||
|
if (y < 0) throw new Error("Invalid position to write to");
|
||
|
|
||
|
// Insert lines until the line at this position is available
|
||
|
while (!(y < this.lines.length))
|
||
|
this.lines.push({ str: "", attr: null });
|
||
|
|
||
|
return this.lines[y];
|
||
|
};
|
||
|
|
||
|
/* Replace the line at specified position, allocating it if necessary */
|
||
|
TermState.prototype.setLine = function setLine(y, line) {
|
||
|
if (typeof y !== "number") line = y, y = this.getCursor()[1];
|
||
|
this.getLine(y);
|
||
|
this.lines[y] = line;
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
/* Write chunk of text (single-line assumed) beginning at position */
|
||
|
TermState.prototype._writeChunk = function _writeChunk(position, chunk, insert) {
|
||
|
var x = position[0], line = this.getLine(position[1]);
|
||
|
if (x < 0) throw new Error("Invalid position to write to");
|
||
|
|
||
|
// Insert spaces until the wanted column is available
|
||
|
while (line.str.length < x)
|
||
|
line.str += " ";
|
||
|
|
||
|
// Write the chunk at position
|
||
|
line.str = line.str.substring(0, x) + chunk + line.str.substring(x + (insert ? 0 : chunk.length));
|
||
|
//TODO: add attribute
|
||
|
|
||
|
this.emit("lineChanged", position[1]);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
/* Remove characters beginning at position */
|
||
|
TermState.prototype.removeChar = function removeChar(n) {
|
||
|
var x = this.cursor[0], line = this.getLine();
|
||
|
if (x < 0) throw new Error("Invalid position to delete from");
|
||
|
|
||
|
// Insert spaces until the wanted column is available
|
||
|
while (line.str.length < x)
|
||
|
line.str += " ";
|
||
|
|
||
|
// Remove characters
|
||
|
line.str = line.str.substring(0, x) + line.str.substring(x + n);
|
||
|
|
||
|
this.emit("lineChanged", this.cursor[1]);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.eraseInLine = function eraseInLine(n) {
|
||
|
var x = this.cursor[0], line = this.getLine();
|
||
|
switch (n || 0) {
|
||
|
case "after":
|
||
|
case 0:
|
||
|
line.str = line.str.substring(0, x);
|
||
|
break;
|
||
|
|
||
|
case "before":
|
||
|
case 1:
|
||
|
var str = "";
|
||
|
while (str.length < x) str += " ";
|
||
|
line.str = str + line.str.substring(x);
|
||
|
break;
|
||
|
|
||
|
case "all":
|
||
|
case 2:
|
||
|
line.str = "";
|
||
|
break;
|
||
|
}
|
||
|
this.emit("lineChanged", this.cursor[1]);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.eraseInDisplay = function eraseInDisplay(n) {
|
||
|
switch (n || 0) {
|
||
|
case "below":
|
||
|
case "after":
|
||
|
case 0:
|
||
|
this.eraseInLine(n);
|
||
|
this.removeLine(this.lines.length - (this.cursor[1]+1), this.cursor[1]+1);
|
||
|
break;
|
||
|
|
||
|
case "above":
|
||
|
case "before":
|
||
|
case 1:
|
||
|
for (var y = 0; y < this.cursor[1]; y++) {
|
||
|
this.lines[y].str = "";
|
||
|
this.emit("lineChanged", y);
|
||
|
}
|
||
|
this.eraseInLine(n);
|
||
|
break;
|
||
|
|
||
|
case "all":
|
||
|
case 2:
|
||
|
this.removeLine(this.lines.length, 0);
|
||
|
break;
|
||
|
}
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.removeLine = function removeLine(n, y) {
|
||
|
if (typeof y !== "number") y = this.cursor[1];
|
||
|
if (n <= 0) return this;
|
||
|
|
||
|
if (y + n > this.lines.length)
|
||
|
n = this.lines.length - y;
|
||
|
if (n <= 0) return this;
|
||
|
|
||
|
this.emit("linesRemoving", y, n);
|
||
|
this.lines.splice(y, n);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.insertLine = function insertLine(n, y) {
|
||
|
if (typeof y !== "number") y = this.cursor[1];
|
||
|
if (n <= 0) return this;
|
||
|
|
||
|
if (y + n > this.rows)
|
||
|
n = this.rows - y;
|
||
|
if (n <= 0) return this;
|
||
|
|
||
|
this.getLine(y);
|
||
|
this.removeLine((this.lines.length + n) - this.rows, this.rows - n);
|
||
|
for (var i = 0; i < n; i++)
|
||
|
this.lines.splice(y, 0, { str: "", attr: null });
|
||
|
this.emit("linesInserted", y, n);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.scroll = function scroll(n) {
|
||
|
if (n > 0) { // up
|
||
|
if (n > this.lines.length) n = this.lines.length; //FIXME: is this okay?
|
||
|
if (n > 0) this.emit("linesScrolling", n);
|
||
|
this.lines = this.lines.slice(n);
|
||
|
} else if (n < 0) { // down
|
||
|
n = -n;
|
||
|
if (n > this.rows) n = this.rows; //FIXME: is this okay?
|
||
|
var extraLines = (this.lines.length + n) - this.rows;
|
||
|
if (extraLines > 0) this.emit("linesScrolling", -extraLines);
|
||
|
this.lines = this.lines.slice(0, this.rows - n);
|
||
|
this.insertLine(n, 0);
|
||
|
}
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
|
||
|
/** HIGH LEVEL **/
|
||
|
|
||
|
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;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.write = function write(chunk) {
|
||
|
chunk.split("\n").forEach(function (line, i) {
|
||
|
if (i > 0) {
|
||
|
// Begin new line
|
||
|
if (this.cursor[1] + 1 >= this.rows)
|
||
|
this.scroll(1);
|
||
|
this.mvCursor(0, 1);
|
||
|
this.getLine();
|
||
|
}
|
||
|
|
||
|
if (!line.length) return;
|
||
|
if (this.getMode("graphic")) this.getLine().code = true;
|
||
|
line = this._graphConvert(line);
|
||
|
this._writeChunk(this.cursor, line, this.getMode("insert"));
|
||
|
this.cursor[0] += line.length;
|
||
|
}.bind(this));
|
||
|
this.emit("cursor");
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.resize = function resize(size) {
|
||
|
if (this.lines.length > size.rows)
|
||
|
this.scroll(this.lines.length - size.rows);
|
||
|
this.rows = size.rows;
|
||
|
this.columns = size.columns;
|
||
|
this.setCursor();
|
||
|
this.emit("resize", size);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.mvCursor = function mvCursor(x, y) {
|
||
|
var cursor = this.getCursor();
|
||
|
return this.setCursor(cursor[0] + x, cursor[1] + y);
|
||
|
};
|
||
|
|
||
|
TermState.prototype.toString = function toString() {
|
||
|
return this.lines.map(function (line) { return line.str; }).join("\n");
|
||
|
};
|
||
|
|
||
|
TermState.prototype.prevLine = function prevLine() {
|
||
|
if (this.cursor[1] > 0) this.mvCursor(0, -1);
|
||
|
else this.scroll(-1);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.nextLine = function nextLine() {
|
||
|
if (this.cursor[1] < this.rows - 1) this.mvCursor(0, +1);
|
||
|
else this.scroll(+1);
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.saveCursor = function saveCursor() {
|
||
|
this.savedCursor = this.getCursor();
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.restoreCursor = function restoreCursor() {
|
||
|
this.cursor = this.savedCursor;
|
||
|
return this.setCursor();
|
||
|
};
|
||
|
|
||
|
TermState.prototype.insertBlank = function insertBlank(n) {
|
||
|
var str = "";
|
||
|
while (str.length < n) str += " ";
|
||
|
return this._writeChunk(this.cursor, str, true);
|
||
|
};
|
||
|
|
||
|
TermState.prototype.eraseCharacters = function eraseCharacters(n) {
|
||
|
var str = "";
|
||
|
while (str.length < n) str += " ";
|
||
|
return this._writeChunk(this.cursor, str, false);
|
||
|
};
|
||
|
|
||
|
TermState.prototype.setScrollRegion = function setScrollRegion(n, m) {
|
||
|
//TODO
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.switchBuffer = function switchBuffer(alt) {
|
||
|
if (this.alt !== alt) {
|
||
|
this.scroll(this.lines.length);
|
||
|
this.alt = alt;
|
||
|
}
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
TermState.prototype.getBufferRowCount = function getBufferRowCount() {
|
||
|
return this.lines.length;
|
||
|
};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* 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.getCursor()[0];
|
||
|
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(this._tabs.indexOf(x) != -1 || (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.getCursor()[0];
|
||
|
}
|
||
|
// Only add the tab position if it is not there already
|
||
|
if (this._tabs.indexOf(pos) != -1) {
|
||
|
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.getCursor()[0]) {
|
||
|
this._tabs.splice(i, 1);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
case "all":
|
||
|
case 3:
|
||
|
this._tabs = [];
|
||
|
break;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
|
||
|
|
||
|
function createTerminal(options) {
|
||
|
var state = new TermState(options);
|
||
|
var term = new Terminal({});
|
||
|
term.state = state;
|
||
|
return term;
|
||
|
}
|
||
|
|
||
|
exports.TermState = TermState;
|
||
|
exports.createTerminal = createTerminal;
|