diff --git a/lib/editor.js b/lib/editor.js new file mode 100644 index 0000000..229740d --- /dev/null +++ b/lib/editor.js @@ -0,0 +1,132 @@ +/** + * Implements a simple select-replace file editor in Telegram. + * It works as follows: + * + * 1. The user invokes the editor with a non-empty file. + * 2. The contents of the file are posted as a message. + * 3. The user replies to that message with (part of) the text. + * The bot will locate that substring in the file contents and track the message. + * 4. The user edits his message. + * The bot will then replace the original substring, save the file and edit its message. + * If there are any problems with saving the file, the editor may detach. + * + * NOTE: sync I/O is used for simplicity; be careful! (TODO) + **/ + +var fs = require("fs"); +var escapeHtml = require("escape-html"); +var utils = require("./utils"); + +function ChunkedString(text) { + this.text = text; + this.chunks = []; +} + +ChunkedString.prototype.findAcquire = function findAcquire(text) { + if (text.length == 0) throw Error("Empty find text not allowed"); + var index = this.text.indexOf(text); + if (index == -1) + throw Error("The substring was not found. Wrapping in tildes may be necessary."); + if (index != this.text.lastIndexOf(text)) + throw Error("There are multiple instances of the passed substring"); + return this.acquire(index, text.length); +}; + +ChunkedString.prototype.acquire = function acquire(offset, length) { + if (offset < 0 || length <= 0 || offset + length > this.text.length) + throw Error("Invalid coordinates"); + for (var i = 0; i < this.chunks.length; i++) { + var c = this.chunks[i]; + if (offset + length > c.offset || c.offset + c.text.length > offset) + throw Error("Chunk overlaps"); + } + var chunk = { offset: offset, text: this.text.substring(offset, offset + length) }; + this.chunks.push(chunk); + return chunk; +}; + +ChunkedString.prototype.release = function release(chunk) { + if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); + this.chunks.splice(index, 1); +}; + +ChunkedString.prototype.modify = function modify(chunk, text) { + if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); + if (text.length == 0) throw Error("Empty replacement not allowed"); + var end = chunk.offset + chunk.text.length; + this.text = this.text.substring(0, chunk.offset) + text + this.text.substring(end); + var diff = text.length - chunk.text.length; + chunk.text = text; + this.chunks.forEach(function (c) { + if (c.offset > chunk.offset) c.offset += diff; + }); +}; + + +function Editor(reply, file, encoding) { + if (!encoding) encoding = "utf-8"; + this.reply = reply; + this.file = file; + this.encoding = encoding; + + // TODO: support for longer files (paginated, etc.) + // FIXME: do it correctly using fd, keeping it open + var contents = fs.readFileSync(file, encoding); + if (contents.length > 1500 || contents.split("\n") > 50) + throw Error("The file is too long"); + + this.contents = new ChunkedString(contents); + this.chunks = {}; // associates each message ID to an active chunk + + this.message = new utils.EditedMessage(reply, this._render(), "HTML"); + this.fileTouched = false; +} + +Editor.prototype._render = function _render() { + if (!this.contents.text.trim()) return "(empty file)"; + return "
" + escapeHtml(this.contents.text) + ""; +}; + +Editor.prototype.handleReply = function handleReply(msg) { + this.message.idPromise.then(function (id) { + if (this.detached) return; + if (msg.reply.id != id) return; + try { + this.chunks[msg.id] = this.contents.findAcquire(msg.text); + } catch (e) { + this.reply.html("%s", e.message); + } + }.bind(this)); +}; + +Editor.prototype.handleEdit = function handleEdit(msg) { + if (this.detached) return false; + if (!Object.hasOwnProperty.call(this.chunks, msg.id)) return false; + this.contents.modify(this.chunks[msg.id], msg.text); + this.attemptSave(); + return true; +}; + +Editor.prototype.attemptSave = function attemptSave() { + this.fileTouched = true; + process.nextTick(function () { + if (!this.fileTouched) return; + if (this.detached) return; + this.fileTouched = false; + + // TODO: check for file external modification, fail then + try { + fs.writeFileSync(this.file, this.contents.text, this.encoding); + } catch (e) { + this.reply.html("Couldn't save file: %s", e.message); + return; + } + this.message.edit(this._render()); + }.bind(this)); +}; + +Editor.prototype.detach = function detach() { + this.detached = true; +}; + +module.exports.Editor = Editor; diff --git a/lib/renderer.js b/lib/renderer.js new file mode 100644 index 0000000..41e23db --- /dev/null +++ b/lib/renderer.js @@ -0,0 +1,267 @@ +/** + * This class keeps a logical mapping of lines to messages. + * It doesn't actually render or send messages, it delegates + * that task to the renderer. + * + * FIXME: do something to prevent extremely long messages to be + * sent (and rejected) when too many lines are inserted in between + * a message. + **/ + +var escapeHtml = require("escape-html"); +var utils = require("./utils"); + +function Renderer(reply, state, options) { + if (!options) options = {}; + this.reply = reply; + this.state = state; + this.options = options; + + this.offset = 0; + this.messages = []; + this.orphanLines = []; + this.unfinishedLine = null; + this.totalLines = 0; + + state.on("lineChanged", this._lineChanged.bind(this)); + state.on("linesRemoving", this._linesRemoving.bind(this)); + state.on("linesScrolling", this._linesScrolling.bind(this)); + state.on("linesInserted", this._linesInserted.bind(this)); + + this.initTimers(); +} + + +/** MESSAGE MAPPING **/ + +Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) { + if (this.totalLines < y) { + this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y)); + this.totalLines = y; + this.newLinesChanged = true; + } +}; + +Renderer.prototype._lineChanged = function _lineChanged(y) { + if (this.state.length - y <= this.orphanLines.length) + this.newLinesChanged = true; +}; + +Renderer.prototype._linesRemoving = function _linesRemoving(y, n) { + this.ensureLinesCreated(this.state.lines.length); + + // Seek until we arrive at the wanted line + y += this.offset; + var idx = 0, lineIdx = 0; + while (y) { + var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; + if (lineIdx < lines.length) { lineIdx++; y--; } + else { idx++; lineIdx = 0; } + } + + // Remove following lines + this.totalLines -= n; + while (n) { + var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; + if (lines.splice(lineIdx, 1).length) n--; + else { idx++; lineIdx = 0; } + } + + if (idx >= this.messages.length) this.newLinesChanged = true; +}; + +Renderer.prototype._linesScrolling = function _linesScrolling(n) { + this.ensureLinesCreated(this.state.lines.length); + + if (n > 0) { + // Scrolling up: increment offset, discarding message if necessary + this.offset += n; + this.totalLines -= n; + while (this.messages.length) { + var message = this.messages[0]; + if (message.lines.length > this.offset) break; + if (message.rendered !== message.ref.lastText) break; + this.offset -= message.lines.length; + this.messages.shift(); + } + } else { + // Scrolling down: just delete bottom lines (leaving them would complicate everything) + n = -n; + this._linesRemoving(this.state.lines.length - n, n); + } +}; + +Renderer.prototype._linesInserted = function _linesInserted(y, n) { + this.ensureLinesCreated(y); + var pos = y; + + // Seek until we arrive at the wanted line, *just before the next one* + y += this.offset; + var idx = 0, lineIdx = 0; + while (true) { + var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; + if (lineIdx < lines.length) { + if (!y) break; + lineIdx++; y--; + } else { idx++; lineIdx = 0; } + } + + // Insert lines + this.totalLines += n; + while (n) { + var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; + lines.splice(lineIdx, 0, this.state.lines[pos]); + n--, lineIdx++, pos++; + } + + if (idx === this.messages.length) this.newLinesChanged = true; +}; + +Renderer.prototype.update = function update() { + this.ensureLinesCreated(this.state.lines.length); + + // Rerender messages, scheduling flush if some changed + var linesChanged = false; + this.messages.forEach(function (message) { + var rendered = this.render(message); + if (rendered !== message.rendered) { + message.rendered = rendered; + linesChanged = true; + } + }.bind(this)); + + if (linesChanged) this.editedLineTimer.set(); + if (this.newLinesChanged) this.newLineTimer.reset(); + this.newLinesChanged = false; + + // Make sure orphan lines are processed + this.orphanLinesUpdated(); +}; + +Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) { + if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen."); + + if (count > this.options.maxLinesEmitted) + count = this.options.maxLinesEmitted; + var lines = this.orphanLines.splice(0, count); + var message = { lines: lines }; + this.messages.push(message); + message.rendered = this.render(message); + var reply = this.reply.silent(silent).disablePreview(disablePreview); + message.ref = new utils.EditedMessage(reply, message.rendered, "HTML"); + this.orphanLinesUpdated(); +}; + + +/** HTML RENDERING **/ + +/* Given a line, return true if potentially monospaced */ +Renderer.prototype.evaluateCode = function evaluateCode(str) { + //FIXME: line just between two code lines should be come code + if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str)) + return true; + return false; +}; + +/* Given a message object, render to an HTML snippet */ +Renderer.prototype.render = function render(message) { + var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString; + var isWhitespace = true, x = this.state.cursor[0]; + + var html = message.lines.map(function (line, idx) { + var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line); + if (!line.code && this.evaluateCode(line.str)) line.code = true; + + var content = line.str; + if (hasCursor || line.str.trim().length) isWhitespace = false; + if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) { + // The message would start with spaces, which would get trimmed by telegram + if (!(hasCursor && x < this.options.startFill.length)) + content = this.options.startFill + content.substring(this.options.startFill.length); + } + + if (hasCursor) + content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x)); + else + content = escapeHtml(content); + + if (line.code) content = "
" + content + "
";
+ return content;
+ }.bind(this)).join("\n");
+
+ if (isWhitespace) return "(empty)";
+ return html;
+};
+
+
+/** FLUSH SCHEDULING **/
+
+Renderer.prototype.initTimers = function initTimers() {
+ // Set when an existent line changes, cancelled when edited lines flushed
+ this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this));
+
+ // Set when a new line is added or changed, cancelled on new lines flush
+ this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this));
+ // Reset when a new line is added or changed, cancelled on new lines flush
+ this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this));
+
+ // Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty
+ this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this));
+
+ this.newChunkTimer.on("active", function () {
+ this.reply.action("typing");
+ }.bind(this));
+ //FIXME: should we emit actions on edits?
+};
+
+Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() {
+ var newLines = this.orphanLines.length - 1;
+ if (newLines >= this.options.maxLinesWait) {
+ // Flush immediately
+ this.flushNew();
+ } else if (newLines > 0) {
+ this.newChunkTimer.set();
+ } else {
+ this.newChunkTimer.cancel();
+ this.newLineTimer.cancel();
+ }
+
+ // Update unfinished line
+ var unfinishedLine = this.orphanLines[this.orphanLines.length - 1];
+ if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns)
+ unfinishedLine = null;
+
+ if (this.unfinishedLine !== unfinishedLine) {
+ this.unfinishedLine = unfinishedLine;
+ this.unfinishedLineTimer.cancel();
+ }
+
+ if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set();
+ else this.unfinishedLineTimer.cancel();
+};
+
+Renderer.prototype.flushEdited = function flushEdited() {
+ this.messages.forEach(function (message) {
+ if (message.rendered !== message.ref.lastText)
+ message.ref.edit(message.rendered);
+ });
+ this.editedLineTimer.cancel();
+};
+
+Renderer.prototype.flushNew = function flushNew() {
+ this.flushEdited();
+ var count = this.orphanLines.length;
+ if (this.unfinishedLine) count--;
+ if (count <= 0) return;
+ this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview);
+};
+
+Renderer.prototype.flushUnfinished = function flushUnfinished() {
+ do this.flushNew(); while (this.orphanLines.length > 1);
+ if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return;
+ this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview);
+};
+
+
+
+exports.Renderer = Renderer;
diff --git a/lib/terminal.js b/lib/terminal.js
new file mode 100644
index 0000000..45b0179
--- /dev/null
+++ b/lib/terminal.js
@@ -0,0 +1,539 @@
+/**
+ * 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
+*