From de22a550ad04f458ff9bd9e7919cf5bb7dd3c655 Mon Sep 17 00:00:00 2001 From: techies Date: Sun, 21 Aug 2022 08:20:08 +0700 Subject: [PATCH] add files to lib --- lib/editor.js | 132 ++++++++++++ lib/renderer.js | 267 ++++++++++++++++++++++++ lib/terminal.js | 539 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/utils.js | 251 ++++++++++++++++++++++ lib/wizard.js | 124 +++++++++++ 5 files changed, 1313 insertions(+) create mode 100644 lib/editor.js create mode 100644 lib/renderer.js create mode 100644 lib/terminal.js create mode 100644 lib/utils.js create mode 100644 lib/wizard.js 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 +*