├── .gitattributes ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── commands.txt ├── config.py ├── lib ├── command.js ├── editor.js ├── renderer.js ├── terminal.js ├── utils.js └── wizard.js ├── package.json ├── server.js ├── setup.sh ├── start.sh └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Elias Benbourenane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: source setup.sh && bash start.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HerokuShell-Rclone 2 | Interface Heroku's shell through Telegram, plus Rclone support 3 | 4 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/mohitjoshi155/HerokuShell-Rclone/tree/master) 5 | 6 | ## Installation 7 | - First get the following prepared: 8 | - A @BotFather bot token. [Tutorial](https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token) 9 | - Your Telegram user ID. Talk with [this](https://t.me/userinfobot) bot 10 | - [OPTIONAL] Your Rclone config, you need to encode it to [Base64](https://www.base64encode.org) 11 | - Next click the Deploy button above, make sure you have a Heroku account 12 | - Fill in the text fields with the information you prepared 13 | - Press the `Deploy` button 14 | - Wait for the app to finish deploying 15 | - Go to the app's dashboard and go onto the 'Resources' tab 16 | - On the `web npm start` worker, click the pencil icon and toggle it off 17 | - On the `worker source setup.sh && bash start.sh` worker, click the the pencil icon and toggle it on 18 | - Now open telegram and use the commands below to interact with your bot 19 | 20 | ## Commands 21 | - To use rclone commands do `/run rclone [param]...` 22 | ``` 23 | run - Execute command 24 | enter - Send input lines to command 25 | type - Type keys into command 26 | control - Type Control+Letter 27 | meta - Send the next typed key with Alt 28 | keypad - Toggle keypad for special keys 29 | redraw - Force the command to repaint 30 | end - Send EOF to command 31 | cancel - Interrupt command 32 | kill - Send signal to process 33 | status - View status and current settings 34 | cd - Change directory 35 | env - Manipulate the environment 36 | shell - Change shell used to run commands 37 | resize - Change the terminal size 38 | setsilent - Enable / disable silent output 39 | setlinkpreviews - Enable / disable link expansion 40 | setinteractive - Enable / disable shell interactive flag 41 | help - Get help 42 | file - View and edit small text files 43 | upload - Upload and overwrite raw files 44 | r - Alias for /run or /enter 45 | ``` 46 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HerokuShell Rclone", 3 | "description": "Bot to interface Heroku shell through Telegram (with Rclone)", 4 | "keywords": [ 5 | "telegram", 6 | "shell", 7 | "rclone", 8 | "heroku" 9 | ], 10 | "repository": "https://github.com/eliasbenb/HerokuShell-Rclone.git", 11 | "env": { 12 | "BOT_TOKEN": { 13 | "description": "The bot token provided by @BotFather", 14 | "required": true 15 | }, 16 | "OWNER_ID": { 17 | "description": "Your Telegram user ID, get it from @userinfobot", 18 | "value": "00000", 19 | "required": true 20 | }, 21 | "RCLONE_CONFIG_BASE64": { 22 | "description": "Your Rclone config encoded using Base64", 23 | "required": false 24 | } 25 | }, 26 | "buildpacks": [ 27 | { 28 | "url": "heroku/nodejs" 29 | }, 30 | { 31 | "url": "heroku/python" 32 | }, 33 | { 34 | "url": "https://github.com/jonathanong/heroku-buildpack-ffmpeg-latest.git" 35 | }, 36 | { 37 | "url": "https://github.com/opendoor-labs/heroku-buildpack-p7zip.git" 38 | }, 39 | { 40 | "url": "https://github.com/heroku/heroku-buildpack-apt.git" 41 | }, 42 | { 43 | "url": "https://github.com/magneto261290/megatools-heroku-buildpack" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /commands.txt: -------------------------------------------------------------------------------- 1 | run - Execute command 2 | enter - Send input lines to command 3 | type - Type keys into command 4 | control - Type Control+Letter 5 | meta - Send the next typed key with Alt 6 | keypad - Toggle keypad for special keys 7 | redraw - Force the command to repaint 8 | end - Send EOF to command 9 | cancel - Interrupt command 10 | kill - Send signal to process 11 | status - View status and current settings 12 | cd - Change directory 13 | env - Manipulate the environment 14 | shell - Change shell used to run commands 15 | resize - Change the terminal size 16 | setsilent - Enable / disable silent output 17 | setlinkpreviews - Enable / disable link expansion 18 | setinteractive - Enable / disable shell interactive flag 19 | help - Get help 20 | file - View and edit small text files 21 | upload - Upload and overwrite raw files 22 | r - Alias for /run or /enter 23 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | w = open('config.json', 'w+') 4 | w.write('{') 5 | w.write('\n') 6 | w.write(' "authToken": "'+os.getenv('BOT_TOKEN')+'",') 7 | w.write('\n') 8 | w.write(' "owner": '+os.getenv('OWNER_ID')) 9 | w.write('\n') 10 | w.write('}') -------------------------------------------------------------------------------- /lib/command.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attaches to a chat, spawns a pty, attaches it to the terminal emulator 3 | * and the renderer and manages them. Handles incoming commands & input, 4 | * and posts complimentary messages such as command itself and output code. 5 | **/ 6 | 7 | var util = require("util"); 8 | var escapeHtml = require("escape-html"); 9 | var pty = require("node-pty"); 10 | var termios = require("node-termios"); 11 | var utils = require("./utils"); 12 | var terminal = require("./terminal"); 13 | var renderer = require("./renderer"); 14 | var tsyms = termios.native.ALL_SYMBOLS; 15 | 16 | function Command(reply, context, command) { 17 | var toUser = reply.destination > 0; 18 | 19 | this.startTime = Date.now(); 20 | this.reply = reply; 21 | this.command = command; 22 | this.pty = pty.spawn(context.shell, [context.interactive ? "-ic" : "-c", command], { 23 | cols: context.size.columns, 24 | rows: context.size.rows, 25 | cwd: context.cwd, 26 | env: context.env, 27 | }); 28 | this.termios = new termios.Termios(this.pty._fd); 29 | this.termios.c_lflag &= ~(tsyms.ISIG | tsyms.IEXTEN); 30 | this.termios.c_lflag &= ~tsyms.ECHO; // disable ECHO 31 | this.termios.c_lflag |= tsyms.ICANON | tsyms.ECHONL; // we need it for /end, it needs to be active beforehand 32 | this.termios.c_iflag = (this.termios.c_iflag & ~(tsyms.INLCR | tsyms.IGNCR)) | tsyms.ICRNL; // CR to NL 33 | this.termios.writeTo(this.pty._fd); 34 | 35 | this.terminal = terminal.createTerminal({ 36 | columns: context.size.columns, 37 | rows: context.size.rows, 38 | }); 39 | this.state = this.terminal.state; 40 | this.renderer = new renderer.Renderer(reply, this.state, { 41 | cursorString: "\uD83D\uDD38", 42 | cursorBlinkString: "\uD83D\uDD38", 43 | hidePreview: !context.linkPreviews, 44 | unfinishedHidePreview: true, 45 | silent: context.silent, 46 | unfinishedSilent: true, 47 | maxLinesWait: toUser ? 20 : 30, 48 | maxLinesEmitted: 30, 49 | lineTime: toUser ? 400 : 1200, 50 | chunkTime: toUser ? 3000 : 6000, 51 | editTime: toUser ? 300 : 2500, 52 | unfinishedTime: toUser ? 1000 : 2000, 53 | startFill: "· ", 54 | }); 55 | this._initKeypad(); 56 | //FIXME: take additional steps to reduce messages sent to group. do typing actions count? 57 | 58 | // Post initial message 59 | this.initialMessage = new utils.EditedMessage(reply, this._renderInitial(), "HTML"); 60 | 61 | // Process command output 62 | this.pty.on("data", this._ptyData.bind(this)); 63 | 64 | // Handle command exit 65 | this.pty.on("exit", this._exit.bind(this)); 66 | } 67 | util.inherits(Command, require("events").EventEmitter); 68 | 69 | Command.prototype._renderInitial = function _renderInitial() { 70 | var content = "", title = this.state.metas.title, badges = this.badges || ""; 71 | if (title) { 72 | content += "" + escapeHtml(title) + "\n"; 73 | content += badges + "$ " + escapeHtml(this.command); 74 | } else { 75 | content += badges + "$ " + escapeHtml(this.command) + ""; 76 | } 77 | return content; 78 | } 79 | 80 | Command.prototype._ptyData = function _ptyData(chunk) { 81 | //FIXME: implement some backpressure, for example, read smaller chunks, stop reading if there are >= 20 lines waiting to be pushed, set the HWM 82 | if ((typeof chunk !== "string") && !(chunk instanceof String)) 83 | throw new Error("Expected a String, you liar."); 84 | this.interacted = true; 85 | this.terminal.write(chunk, "utf-8", this._update.bind(this)); 86 | }; 87 | 88 | Command.prototype._update = function _update() { 89 | this.initialMessage.edit(this._renderInitial()); 90 | this.renderer.update(); 91 | }; 92 | 93 | Command.prototype.resize = function resize(size) { 94 | this.interacted = true; 95 | this.metaActive = false; 96 | this.state.resize(size); 97 | this._update(); 98 | this.pty.resize(size.columns, size.rows); 99 | }; 100 | 101 | Command.prototype.redraw = function redraw() { 102 | this.interacted = true; 103 | this.metaActive = false; 104 | this.pty.redraw(); 105 | }; 106 | 107 | Command.prototype.sendSignal = function sendSignal(signal, group) { 108 | this.interacted = true; 109 | this.metaActive = false; 110 | var pid = this.pty.pid; 111 | if (group) pid = -pid; 112 | process.kill(pid, signal); 113 | }; 114 | 115 | Command.prototype.sendEof = function sendEof() { 116 | this.interacted = true; 117 | this.metaActive = false; 118 | 119 | // I don't know how to cause a 'buffer flush to the app' (the effect of Control+D) 120 | // without actually pressing it into the console. So let's do just that. 121 | // TTY needs to be in ICANON mode from the start, enabling it now doesn't work 122 | 123 | // write EOF control character 124 | this.termios.loadFrom(this.pty._fd); 125 | this.pty.write(Buffer.from([ this.termios.c_cc[tsyms.VEOF] ])); 126 | }; 127 | 128 | Command.prototype._exit = function _exit(code, signal) { 129 | this._update(); 130 | this.renderer.flushUnfinished(); 131 | 132 | 133 | //FIXME: could wait until all edits are made before posting exited message 134 | if ((Date.now() - this.startTime) < 2000 && !signal && code === 0 && !this.interacted) { 135 | // For short lived commands that completed without output, we simply add a tick to the original message 136 | this.badges = "\u2705 "; 137 | this.initialMessage.edit(this._renderInitial()); 138 | } else { 139 | if (signal) 140 | this.reply.html("\uD83D\uDC80 Killed by %s.", utils.formatSignal(signal)); 141 | else if (code === 0) 142 | this.reply.html("\u2705 Exited correctly."); 143 | else 144 | this.reply.html("\u26D4 Exited with %s.", code); 145 | } 146 | 147 | this._removeKeypad(); 148 | this.emit("exit"); 149 | }; 150 | 151 | Command.prototype.handleReply = function handleReply(msg) { 152 | //FIXME: feature: if photo, file, video, voice or music, put the terminal in raw mode, hold off further input, pipe binary asset to terminal, restore 153 | //Flags we would need to touch: -INLCR -IGNCR -ICRNL -IUCLC -ISIG -ICANON -IEXTEN, and also for convenience -ECHO -ECHONL 154 | 155 | if (msg.type !== "text") return false; 156 | this.sendInput(msg.text); 157 | }; 158 | 159 | Command.prototype.sendInput = function sendInput(text, noTerminate) { 160 | this.interacted = true; 161 | text = text.replace(/\n/g, "\r"); 162 | if (!noTerminate) text += "\r"; 163 | if (this.metaActive) text = "\x1b" + text; 164 | this.pty.write(text); 165 | this.metaActive = false; 166 | }; 167 | 168 | Command.prototype.toggleMeta = function toggleMeta(metaActive) { 169 | if (metaActive === undefined) metaActive = !this.metaActive; 170 | this.metaActive = metaActive; 171 | }; 172 | 173 | Command.prototype.setSilent = function setSilent(silent) { 174 | this.renderer.options.silent = silent; 175 | }; 176 | 177 | Command.prototype.setLinkPreviews = function setLinkPreviews(linkPreviews) { 178 | this.renderer.options.hidePreview = !linkPreviews; 179 | }; 180 | 181 | Command.prototype._initKeypad = function _initKeypad() { 182 | this.keypadToken = utils.generateToken(); 183 | 184 | var keys = { 185 | esc: { label: "ESC", content: "\x1b" }, 186 | tab: { label: "⇥", content: "\t" }, 187 | enter: { label: "⏎", content: "\r" }, 188 | backspace: { label: "↤", content: "\x7F" }, 189 | space: { label: " ", content: " " }, 190 | 191 | up: { label: "↑", content: "\x1b[A", appKeypadContent: "\x1bOA" }, 192 | down: { label: "↓", content: "\x1b[B", appKeypadContent: "\x1bOB" }, 193 | right: { label: "→", content: "\x1b[C", appKeypadContent: "\x1bOC" }, 194 | left: { label: "←", content: "\x1b[D", appKeypadContent: "\x1bOD" }, 195 | 196 | insert: { label: "INS", content: "\x1b[2~" }, 197 | del: { label: "DEL", content: "\x1b[3~" }, 198 | home: { label: "⇱", content: "\x1bOH" }, 199 | end: { label: "⇲", content: "\x1bOF" }, 200 | 201 | prevPage: { label: "⇈", content: "\x1b[5~" }, 202 | nextPage: { label: "⇊", content: "\x1b[6~" }, 203 | }; 204 | 205 | var keypad = [ 206 | [ "esc", "up", "backspace", "del" ], 207 | [ "left", "space", "right", "home" ], 208 | [ "tab", "down", "enter", "end" ], 209 | ]; 210 | 211 | this.buttons = []; 212 | this.inlineKeyboard = keypad.map(function (row) { 213 | return row.map(function (name) { 214 | var button = keys[name]; 215 | var data = JSON.stringify({ token: this.keypadToken, button: this.buttons.length }); 216 | var keyboardButton = { text: button.label, callback_data: data }; 217 | this.buttons.push(button); 218 | return keyboardButton; 219 | }.bind(this)); 220 | }.bind(this)); 221 | 222 | this.reply.bot.callback(function (query, next) { 223 | try { 224 | var data = JSON.parse(query.data); 225 | } catch (e) { return next(); } 226 | if (data.token !== this.keypadToken) return next(); 227 | this._keypadPressed(data.button, query); 228 | }.bind(this)); 229 | }; 230 | 231 | Command.prototype.toggleKeypad = function toggleKeypad() { 232 | if (this.keypadMessage) { 233 | this.keypadMessage.markup = null; 234 | this.keypadMessage.refresh(); 235 | this.keypadMessage = null; 236 | return; 237 | } 238 | 239 | // FIXME: this is pretty badly implemented, we should wait until last message (or message with cursor) has known id 240 | var messages = this.renderer.messages; 241 | var msg = messages[messages.length - 1].ref; 242 | msg.markup = {inline_keyboard: this.inlineKeyboard}; 243 | msg.refresh(); 244 | this.keypadMessage = msg; 245 | }; 246 | 247 | Command.prototype._keypadPressed = function _keypadPressed(id, query) { 248 | this.interacted = true; 249 | if (typeof id !== "number" || !(id in this.buttons)) return; 250 | var button = this.buttons[id]; 251 | var content = button.content; 252 | if (button.appKeypadContent !== undefined && this.state.getMode("appKeypad")) 253 | content = button.appKeypadContent; 254 | this.pty.write(content); 255 | query.answer(); 256 | }; 257 | 258 | Command.prototype._removeKeypad = function _removeKeypad() { 259 | if (this.keypadMessage) this.toggleKeypad(); 260 | }; 261 | 262 | 263 | 264 | exports.Command = Command; 265 | -------------------------------------------------------------------------------- /lib/editor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements a simple select-replace file editor in Telegram. 3 | * It works as follows: 4 | * 5 | * 1. The user invokes the editor with a non-empty file. 6 | * 2. The contents of the file are posted as a message. 7 | * 3. The user replies to that message with (part of) the text. 8 | * The bot will locate that substring in the file contents and track the message. 9 | * 4. The user edits his message. 10 | * The bot will then replace the original substring, save the file and edit its message. 11 | * If there are any problems with saving the file, the editor may detach. 12 | * 13 | * NOTE: sync I/O is used for simplicity; be careful! (TODO) 14 | **/ 15 | 16 | var fs = require("fs"); 17 | var escapeHtml = require("escape-html"); 18 | var utils = require("./utils"); 19 | 20 | function ChunkedString(text) { 21 | this.text = text; 22 | this.chunks = []; 23 | } 24 | 25 | ChunkedString.prototype.findAcquire = function findAcquire(text) { 26 | if (text.length == 0) throw Error("Empty find text not allowed"); 27 | var index = this.text.indexOf(text); 28 | if (index == -1) 29 | throw Error("The substring was not found. Wrapping in tildes may be necessary."); 30 | if (index != this.text.lastIndexOf(text)) 31 | throw Error("There are multiple instances of the passed substring"); 32 | return this.acquire(index, text.length); 33 | }; 34 | 35 | ChunkedString.prototype.acquire = function acquire(offset, length) { 36 | if (offset < 0 || length <= 0 || offset + length > this.text.length) 37 | throw Error("Invalid coordinates"); 38 | for (var i = 0; i < this.chunks.length; i++) { 39 | var c = this.chunks[i]; 40 | if (offset + length > c.offset || c.offset + c.text.length > offset) 41 | throw Error("Chunk overlaps"); 42 | } 43 | var chunk = { offset: offset, text: this.text.substring(offset, offset + length) }; 44 | this.chunks.push(chunk); 45 | return chunk; 46 | }; 47 | 48 | ChunkedString.prototype.release = function release(chunk) { 49 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 50 | this.chunks.splice(index, 1); 51 | }; 52 | 53 | ChunkedString.prototype.modify = function modify(chunk, text) { 54 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 55 | if (text.length == 0) throw Error("Empty replacement not allowed"); 56 | var end = chunk.offset + chunk.text.length; 57 | this.text = this.text.substring(0, chunk.offset) + text + this.text.substring(end); 58 | var diff = text.length - chunk.text.length; 59 | chunk.text = text; 60 | this.chunks.forEach(function (c) { 61 | if (c.offset > chunk.offset) c.offset += diff; 62 | }); 63 | }; 64 | 65 | 66 | function Editor(reply, file, encoding) { 67 | if (!encoding) encoding = "utf-8"; 68 | this.reply = reply; 69 | this.file = file; 70 | this.encoding = encoding; 71 | 72 | // TODO: support for longer files (paginated, etc.) 73 | // FIXME: do it correctly using fd, keeping it open 74 | var contents = fs.readFileSync(file, encoding); 75 | if (contents.length > 1500 || contents.split("\n") > 50) 76 | throw Error("The file is too long"); 77 | 78 | this.contents = new ChunkedString(contents); 79 | this.chunks = {}; // associates each message ID to an active chunk 80 | 81 | this.message = new utils.EditedMessage(reply, this._render(), "HTML"); 82 | this.fileTouched = false; 83 | } 84 | 85 | Editor.prototype._render = function _render() { 86 | if (!this.contents.text.trim()) return "(empty file)"; 87 | return "
" + escapeHtml(this.contents.text) + "
"; 88 | }; 89 | 90 | Editor.prototype.handleReply = function handleReply(msg) { 91 | this.message.idPromise.then(function (id) { 92 | if (this.detached) return; 93 | if (msg.reply.id != id) return; 94 | try { 95 | this.chunks[msg.id] = this.contents.findAcquire(msg.text); 96 | } catch (e) { 97 | this.reply.html("%s", e.message); 98 | } 99 | }.bind(this)); 100 | }; 101 | 102 | Editor.prototype.handleEdit = function handleEdit(msg) { 103 | if (this.detached) return false; 104 | if (!Object.hasOwnProperty.call(this.chunks, msg.id)) return false; 105 | this.contents.modify(this.chunks[msg.id], msg.text); 106 | this.attemptSave(); 107 | return true; 108 | }; 109 | 110 | Editor.prototype.attemptSave = function attemptSave() { 111 | this.fileTouched = true; 112 | process.nextTick(function () { 113 | if (!this.fileTouched) return; 114 | if (this.detached) return; 115 | this.fileTouched = false; 116 | 117 | // TODO: check for file external modification, fail then 118 | try { 119 | fs.writeFileSync(this.file, this.contents.text, this.encoding); 120 | } catch (e) { 121 | this.reply.html("Couldn't save file: %s", e.message); 122 | return; 123 | } 124 | this.message.edit(this._render()); 125 | }.bind(this)); 126 | }; 127 | 128 | Editor.prototype.detach = function detach() { 129 | this.detached = true; 130 | }; 131 | 132 | module.exports.Editor = Editor; 133 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This class keeps a logical mapping of lines to messages. 3 | * It doesn't actually render or send messages, it delegates 4 | * that task to the renderer. 5 | * 6 | * FIXME: do something to prevent extremely long messages to be 7 | * sent (and rejected) when too many lines are inserted in between 8 | * a message. 9 | **/ 10 | 11 | var escapeHtml = require("escape-html"); 12 | var utils = require("./utils"); 13 | 14 | function Renderer(reply, state, options) { 15 | if (!options) options = {}; 16 | this.reply = reply; 17 | this.state = state; 18 | this.options = options; 19 | 20 | this.offset = 0; 21 | this.messages = []; 22 | this.orphanLines = []; 23 | this.unfinishedLine = null; 24 | this.totalLines = 0; 25 | 26 | state.on("lineChanged", this._lineChanged.bind(this)); 27 | state.on("linesRemoving", this._linesRemoving.bind(this)); 28 | state.on("linesScrolling", this._linesScrolling.bind(this)); 29 | state.on("linesInserted", this._linesInserted.bind(this)); 30 | 31 | this.initTimers(); 32 | } 33 | 34 | 35 | /** MESSAGE MAPPING **/ 36 | 37 | Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) { 38 | if (this.totalLines < y) { 39 | this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y)); 40 | this.totalLines = y; 41 | this.newLinesChanged = true; 42 | } 43 | }; 44 | 45 | Renderer.prototype._lineChanged = function _lineChanged(y) { 46 | if (this.state.length - y <= this.orphanLines.length) 47 | this.newLinesChanged = true; 48 | }; 49 | 50 | Renderer.prototype._linesRemoving = function _linesRemoving(y, n) { 51 | this.ensureLinesCreated(this.state.lines.length); 52 | 53 | // Seek until we arrive at the wanted line 54 | y += this.offset; 55 | var idx = 0, lineIdx = 0; 56 | while (y) { 57 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 58 | if (lineIdx < lines.length) { lineIdx++; y--; } 59 | else { idx++; lineIdx = 0; } 60 | } 61 | 62 | // Remove following lines 63 | this.totalLines -= n; 64 | while (n) { 65 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 66 | if (lines.splice(lineIdx, 1).length) n--; 67 | else { idx++; lineIdx = 0; } 68 | } 69 | 70 | if (idx >= this.messages.length) this.newLinesChanged = true; 71 | }; 72 | 73 | Renderer.prototype._linesScrolling = function _linesScrolling(n) { 74 | this.ensureLinesCreated(this.state.lines.length); 75 | 76 | if (n > 0) { 77 | // Scrolling up: increment offset, discarding message if necessary 78 | this.offset += n; 79 | this.totalLines -= n; 80 | while (this.messages.length) { 81 | var message = this.messages[0]; 82 | if (message.lines.length > this.offset) break; 83 | if (message.rendered !== message.ref.lastText) break; 84 | this.offset -= message.lines.length; 85 | this.messages.shift(); 86 | } 87 | } else { 88 | // Scrolling down: just delete bottom lines (leaving them would complicate everything) 89 | n = -n; 90 | this._linesRemoving(this.state.lines.length - n, n); 91 | } 92 | }; 93 | 94 | Renderer.prototype._linesInserted = function _linesInserted(y, n) { 95 | this.ensureLinesCreated(y); 96 | var pos = y; 97 | 98 | // Seek until we arrive at the wanted line, *just before the next one* 99 | y += this.offset; 100 | var idx = 0, lineIdx = 0; 101 | while (true) { 102 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 103 | if (lineIdx < lines.length) { 104 | if (!y) break; 105 | lineIdx++; y--; 106 | } else { idx++; lineIdx = 0; } 107 | } 108 | 109 | // Insert lines 110 | this.totalLines += n; 111 | while (n) { 112 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 113 | lines.splice(lineIdx, 0, this.state.lines[pos]); 114 | n--, lineIdx++, pos++; 115 | } 116 | 117 | if (idx === this.messages.length) this.newLinesChanged = true; 118 | }; 119 | 120 | Renderer.prototype.update = function update() { 121 | this.ensureLinesCreated(this.state.lines.length); 122 | 123 | // Rerender messages, scheduling flush if some changed 124 | var linesChanged = false; 125 | this.messages.forEach(function (message) { 126 | var rendered = this.render(message); 127 | if (rendered !== message.rendered) { 128 | message.rendered = rendered; 129 | linesChanged = true; 130 | } 131 | }.bind(this)); 132 | 133 | if (linesChanged) this.editedLineTimer.set(); 134 | if (this.newLinesChanged) this.newLineTimer.reset(); 135 | this.newLinesChanged = false; 136 | 137 | // Make sure orphan lines are processed 138 | this.orphanLinesUpdated(); 139 | }; 140 | 141 | Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) { 142 | if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen."); 143 | 144 | if (count > this.options.maxLinesEmitted) 145 | count = this.options.maxLinesEmitted; 146 | var lines = this.orphanLines.splice(0, count); 147 | var message = { lines: lines }; 148 | this.messages.push(message); 149 | message.rendered = this.render(message); 150 | var reply = this.reply.silent(silent).disablePreview(disablePreview); 151 | message.ref = new utils.EditedMessage(reply, message.rendered, "HTML"); 152 | this.orphanLinesUpdated(); 153 | }; 154 | 155 | 156 | /** HTML RENDERING **/ 157 | 158 | /* Given a line, return true if potentially monospaced */ 159 | Renderer.prototype.evaluateCode = function evaluateCode(str) { 160 | //FIXME: line just between two code lines should be come code 161 | if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str)) 162 | return true; 163 | return false; 164 | }; 165 | 166 | /* Given a message object, render to an HTML snippet */ 167 | Renderer.prototype.render = function render(message) { 168 | var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString; 169 | var isWhitespace = true, x = this.state.cursor[0]; 170 | 171 | var html = message.lines.map(function (line, idx) { 172 | var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line); 173 | if (!line.code && this.evaluateCode(line.str)) line.code = true; 174 | 175 | var content = line.str; 176 | if (hasCursor || line.str.trim().length) isWhitespace = false; 177 | if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) { 178 | // The message would start with spaces, which would get trimmed by telegram 179 | if (!(hasCursor && x < this.options.startFill.length)) 180 | content = this.options.startFill + content.substring(this.options.startFill.length); 181 | } 182 | 183 | if (hasCursor) 184 | content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x)); 185 | else 186 | content = escapeHtml(content); 187 | 188 | if (line.code) content = "" + content + ""; 189 | return content; 190 | }.bind(this)).join("\n"); 191 | 192 | if (isWhitespace) return "(empty)"; 193 | return html; 194 | }; 195 | 196 | 197 | /** FLUSH SCHEDULING **/ 198 | 199 | Renderer.prototype.initTimers = function initTimers() { 200 | // Set when an existent line changes, cancelled when edited lines flushed 201 | this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this)); 202 | 203 | // Set when a new line is added or changed, cancelled on new lines flush 204 | this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this)); 205 | // Reset when a new line is added or changed, cancelled on new lines flush 206 | this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this)); 207 | 208 | // Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty 209 | this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this)); 210 | 211 | this.newChunkTimer.on("active", function () { 212 | this.reply.action("typing"); 213 | }.bind(this)); 214 | //FIXME: should we emit actions on edits? 215 | }; 216 | 217 | Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() { 218 | var newLines = this.orphanLines.length - 1; 219 | if (newLines >= this.options.maxLinesWait) { 220 | // Flush immediately 221 | this.flushNew(); 222 | } else if (newLines > 0) { 223 | this.newChunkTimer.set(); 224 | } else { 225 | this.newChunkTimer.cancel(); 226 | this.newLineTimer.cancel(); 227 | } 228 | 229 | // Update unfinished line 230 | var unfinishedLine = this.orphanLines[this.orphanLines.length - 1]; 231 | if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns) 232 | unfinishedLine = null; 233 | 234 | if (this.unfinishedLine !== unfinishedLine) { 235 | this.unfinishedLine = unfinishedLine; 236 | this.unfinishedLineTimer.cancel(); 237 | } 238 | 239 | if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set(); 240 | else this.unfinishedLineTimer.cancel(); 241 | }; 242 | 243 | Renderer.prototype.flushEdited = function flushEdited() { 244 | this.messages.forEach(function (message) { 245 | if (message.rendered !== message.ref.lastText) 246 | message.ref.edit(message.rendered); 247 | }); 248 | this.editedLineTimer.cancel(); 249 | }; 250 | 251 | Renderer.prototype.flushNew = function flushNew() { 252 | this.flushEdited(); 253 | var count = this.orphanLines.length; 254 | if (this.unfinishedLine) count--; 255 | if (count <= 0) return; 256 | this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview); 257 | }; 258 | 259 | Renderer.prototype.flushUnfinished = function flushUnfinished() { 260 | do this.flushNew(); while (this.orphanLines.length > 1); 261 | if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return; 262 | this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview); 263 | }; 264 | 265 | 266 | 267 | exports.Renderer = Renderer; 268 | -------------------------------------------------------------------------------- /lib/terminal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements a terminal emulator. We use terminal.js for the 3 | * dirty work of parsing the escape sequences, but implement our 4 | * own TermState, with some quirks when compared to a standard 5 | * terminal emulator: 6 | * 7 | * - Lines and characters are created on demand. The terminal 8 | * starts out with no content. The reason being, you don't want 9 | * empty lines to be immediately pushed to your Telegram chat 10 | * after starting a command. 11 | * 12 | * - Allows lines of length higher than the column size. The extra 13 | * characters are appended but the cursor keeps right at the edge. 14 | * Telegram already wraps long lines, having them wrapped by the 15 | * terminal would be ugly. 16 | * 17 | * - Graphic attributes not implemented for now (would not be used 18 | * for Telegram anyways). 19 | * 20 | * - Doesn't have an alternate buffer for now (wouldn't make much 21 | * sense for Telegram rendering...) FIXME 22 | * 23 | * The terminal emulator emits events when lines get inserted, changed, 24 | * removed or go out of view, similar to the original TermState. 25 | **/ 26 | 27 | var util = require("util"); 28 | var Terminal = require("terminal.js"); 29 | 30 | //FIXME: investigate using a patched palette for better support on android 31 | var GRAPHICS = { 32 | '`': '\u25C6', 33 | 'a': '\u2592', 34 | 'b': '\u2409', 35 | 'c': '\u240C', 36 | 'd': '\u240D', 37 | 'e': '\u240A', 38 | 'f': '\u00B0', 39 | 'g': '\u00B1', 40 | 'h': '\u2424', 41 | 'i': '\u240B', 42 | 'j': '\u2518', 43 | 'k': '\u2510', 44 | 'l': '\u250C', 45 | 'm': '\u2514', 46 | 'n': '\u253C', 47 | 'o': '\u23BA', 48 | 'p': '\u23BB', 49 | 'q': '\u2500', 50 | 'r': '\u23BC', 51 | 's': '\u23BD', 52 | 't': '\u251C', 53 | 'u': '\u2524', 54 | 'v': '\u2534', 55 | 'w': '\u252C', 56 | 'x': '\u2502', 57 | 'y': '\u2264', 58 | 'z': '\u2265', 59 | '{': '\u03C0', 60 | '|': '\u2260', 61 | '}': '\u00A3', 62 | '~': '\u00B7' 63 | }; 64 | 65 | 66 | /** INITIALIZATION & ACCESSORS **/ 67 | 68 | function TermState(options) { 69 | if (!options) options = {}; 70 | this.rows = options.rows || 24; 71 | this.columns = options.columns || 80; 72 | 73 | this.defaultAttributes = { 74 | fg: null, 75 | bg: null, 76 | bold: false, 77 | underline: false, 78 | italic: false, 79 | blink: false, 80 | inverse: false, 81 | }; 82 | this.reset(); 83 | } 84 | util.inherits(TermState, require("events").EventEmitter); 85 | 86 | TermState.prototype.reset = function reset() { 87 | this.lines = []; 88 | this.cursor = [0,0]; 89 | this.savedCursor = [0,0]; 90 | 91 | this.modes = { 92 | cursor: true, 93 | cursorBlink: false, 94 | appKeypad: false, 95 | wrap: true, 96 | insert: false, 97 | crlf: false, 98 | mousebtn: false, 99 | mousemtn: false, 100 | reverse: false, 101 | graphic: false, 102 | mousesgr: false, 103 | }; 104 | this.attributes = Object.create(this.defaultAttributes); 105 | this._charsets = { 106 | "G0": "unicode", 107 | "G1": "unicode", 108 | "G2": "unicode", 109 | "G3": "unicode", 110 | }; 111 | this._mappedCharset = "G0"; 112 | this._mappedCharsetNext = "G0"; 113 | this.metas = { 114 | title: '', 115 | icon: '' 116 | }; 117 | this.leds = {}; 118 | this._tabs = []; 119 | this.emit("reset"); 120 | }; 121 | 122 | function getGenericSetter(field) { 123 | return function genericSetter(name, value) { 124 | this[field + "s"][name] = value; 125 | this.emit(field, name); 126 | }; 127 | } 128 | 129 | TermState.prototype.setMode = getGenericSetter("mode"); 130 | TermState.prototype.setMeta = getGenericSetter("meta"); 131 | TermState.prototype.setAttribute = getGenericSetter("attribute"); 132 | TermState.prototype.setLed = getGenericSetter("led"); 133 | 134 | TermState.prototype.getMode = function getMode(mode) { 135 | return this.modes[mode]; 136 | }; 137 | 138 | TermState.prototype.getLed = function getLed(led) { 139 | return !!this.leds[led]; 140 | }; 141 | 142 | TermState.prototype.ledOn = function ledOn(led) { 143 | this.setLed(led, true); 144 | return this; 145 | }; 146 | 147 | TermState.prototype.resetLeds = function resetLeds() { 148 | this.leds = {}; 149 | return this; 150 | }; 151 | 152 | TermState.prototype.resetAttribute = function resetAttribute(name) { 153 | this.attributes[name] = this.defaultAttributes[name]; 154 | return this; 155 | }; 156 | 157 | TermState.prototype.mapCharset = function(target, nextOnly) { 158 | this._mappedCharset = target; 159 | if (!nextOnly) this._mappedCharsetNext = target; 160 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 161 | }; 162 | 163 | TermState.prototype.selectCharset = function(charset, target) { 164 | if (!target) target = this._mappedCharset; 165 | this._charsets[target] = charset; 166 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 167 | }; 168 | 169 | 170 | /** CORE METHODS **/ 171 | 172 | /* Move the cursor */ 173 | TermState.prototype.setCursor = function setCursor(x, y) { 174 | if (typeof x === 'number') 175 | this.cursor[0] = x; 176 | 177 | if (typeof y === 'number') 178 | this.cursor[1] = y; 179 | 180 | this.cursor = this.getCursor(); 181 | this.emit("cursor"); 182 | return this; 183 | }; 184 | 185 | /* Get the real cursor position (the logical one may be off-bounds) */ 186 | TermState.prototype.getCursor = function getCursor() { 187 | var x = this.cursor[0], y = this.cursor[1]; 188 | 189 | if (x >= this.columns) x = this.columns - 1; 190 | else if (x < 0) x = 0; 191 | 192 | if (y >= this.rows) y = this.rows - 1; 193 | else if (y < 0) y = 0; 194 | 195 | return [x,y]; 196 | }; 197 | 198 | /* Get the line at specified position, allocating it if necessary */ 199 | TermState.prototype.getLine = function getLine(y) { 200 | if (typeof y !== "number") y = this.getCursor()[1]; 201 | if (y < 0) throw new Error("Invalid position to write to"); 202 | 203 | // Insert lines until the line at this position is available 204 | while (!(y < this.lines.length)) 205 | this.lines.push({ str: "", attr: null }); 206 | 207 | return this.lines[y]; 208 | }; 209 | 210 | /* Replace the line at specified position, allocating it if necessary */ 211 | TermState.prototype.setLine = function setLine(y, line) { 212 | if (typeof y !== "number") line = y, y = this.getCursor()[1]; 213 | this.getLine(y); 214 | this.lines[y] = line; 215 | return this; 216 | }; 217 | 218 | /* Write chunk of text (single-line assumed) beginning at position */ 219 | TermState.prototype._writeChunk = function _writeChunk(position, chunk, insert) { 220 | var x = position[0], line = this.getLine(position[1]); 221 | if (x < 0) throw new Error("Invalid position to write to"); 222 | 223 | // Insert spaces until the wanted column is available 224 | while (line.str.length < x) 225 | line.str += " "; 226 | 227 | // Write the chunk at position 228 | line.str = line.str.substring(0, x) + chunk + line.str.substring(x + (insert ? 0 : chunk.length)); 229 | //TODO: add attribute 230 | 231 | this.emit("lineChanged", position[1]); 232 | return this; 233 | }; 234 | 235 | /* Remove characters beginning at position */ 236 | TermState.prototype.removeChar = function removeChar(n) { 237 | var x = this.cursor[0], line = this.getLine(); 238 | if (x < 0) throw new Error("Invalid position to delete from"); 239 | 240 | // Insert spaces until the wanted column is available 241 | while (line.str.length < x) 242 | line.str += " "; 243 | 244 | // Remove characters 245 | line.str = line.str.substring(0, x) + line.str.substring(x + n); 246 | 247 | this.emit("lineChanged", this.cursor[1]); 248 | return this; 249 | }; 250 | 251 | TermState.prototype.eraseInLine = function eraseInLine(n) { 252 | var x = this.cursor[0], line = this.getLine(); 253 | switch (n || 0) { 254 | case "after": 255 | case 0: 256 | line.str = line.str.substring(0, x); 257 | break; 258 | 259 | case "before": 260 | case 1: 261 | var str = ""; 262 | while (str.length < x) str += " "; 263 | line.str = str + line.str.substring(x); 264 | break; 265 | 266 | case "all": 267 | case 2: 268 | line.str = ""; 269 | break; 270 | } 271 | this.emit("lineChanged", this.cursor[1]); 272 | return this; 273 | }; 274 | 275 | TermState.prototype.eraseInDisplay = function eraseInDisplay(n) { 276 | switch (n || 0) { 277 | case "below": 278 | case "after": 279 | case 0: 280 | this.eraseInLine(n); 281 | this.removeLine(this.lines.length - (this.cursor[1]+1), this.cursor[1]+1); 282 | break; 283 | 284 | case "above": 285 | case "before": 286 | case 1: 287 | for (var y = 0; y < this.cursor[1]; y++) { 288 | this.lines[y].str = ""; 289 | this.emit("lineChanged", y); 290 | } 291 | this.eraseInLine(n); 292 | break; 293 | 294 | case "all": 295 | case 2: 296 | this.removeLine(this.lines.length, 0); 297 | break; 298 | } 299 | return this; 300 | }; 301 | 302 | TermState.prototype.removeLine = function removeLine(n, y) { 303 | if (typeof y !== "number") y = this.cursor[1]; 304 | if (n <= 0) return this; 305 | 306 | if (y + n > this.lines.length) 307 | n = this.lines.length - y; 308 | if (n <= 0) return this; 309 | 310 | this.emit("linesRemoving", y, n); 311 | this.lines.splice(y, n); 312 | return this; 313 | }; 314 | 315 | TermState.prototype.insertLine = function insertLine(n, y) { 316 | if (typeof y !== "number") y = this.cursor[1]; 317 | if (n <= 0) return this; 318 | 319 | if (y + n > this.rows) 320 | n = this.rows - y; 321 | if (n <= 0) return this; 322 | 323 | this.getLine(y); 324 | this.removeLine((this.lines.length + n) - this.rows, this.rows - n); 325 | for (var i = 0; i < n; i++) 326 | this.lines.splice(y, 0, { str: "", attr: null }); 327 | this.emit("linesInserted", y, n); 328 | return this; 329 | }; 330 | 331 | TermState.prototype.scroll = function scroll(n) { 332 | if (n > 0) { // up 333 | if (n > this.lines.length) n = this.lines.length; //FIXME: is this okay? 334 | if (n > 0) this.emit("linesScrolling", n); 335 | this.lines = this.lines.slice(n); 336 | } else if (n < 0) { // down 337 | n = -n; 338 | if (n > this.rows) n = this.rows; //FIXME: is this okay? 339 | var extraLines = (this.lines.length + n) - this.rows; 340 | if (extraLines > 0) this.emit("linesScrolling", -extraLines); 341 | this.lines = this.lines.slice(0, this.rows - n); 342 | this.insertLine(n, 0); 343 | } 344 | return this; 345 | }; 346 | 347 | 348 | /** HIGH LEVEL **/ 349 | 350 | TermState.prototype._graphConvert = function(content) { 351 | // optimization for 99% of the time 352 | if(this._mappedCharset === this._mappedCharsetNext && !this.modes.graphic) { 353 | return content; 354 | } 355 | 356 | var result = "", i; 357 | for(i = 0; i < content.length; i++) { 358 | result += (this.modes.graphic && content[i] in GRAPHICS) ? 359 | GRAPHICS[content[i]] : 360 | content[i]; 361 | this._mappedCharset = this._mappedCharsetNext; 362 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility 363 | } 364 | return result; 365 | }; 366 | 367 | TermState.prototype.write = function write(chunk) { 368 | chunk.split("\n").forEach(function (line, i) { 369 | if (i > 0) { 370 | // Begin new line 371 | if (this.cursor[1] + 1 >= this.rows) 372 | this.scroll(1); 373 | this.mvCursor(0, 1); 374 | this.getLine(); 375 | } 376 | 377 | if (!line.length) return; 378 | if (this.getMode("graphic")) this.getLine().code = true; 379 | line = this._graphConvert(line); 380 | this._writeChunk(this.cursor, line, this.getMode("insert")); 381 | this.cursor[0] += line.length; 382 | }.bind(this)); 383 | this.emit("cursor"); 384 | return this; 385 | }; 386 | 387 | TermState.prototype.resize = function resize(size) { 388 | if (this.lines.length > size.rows) 389 | this.scroll(this.lines.length - size.rows); 390 | this.rows = size.rows; 391 | this.columns = size.columns; 392 | this.setCursor(); 393 | this.emit("resize", size); 394 | return this; 395 | }; 396 | 397 | TermState.prototype.mvCursor = function mvCursor(x, y) { 398 | var cursor = this.getCursor(); 399 | return this.setCursor(cursor[0] + x, cursor[1] + y); 400 | }; 401 | 402 | TermState.prototype.toString = function toString() { 403 | return this.lines.map(function (line) { return line.str; }).join("\n"); 404 | }; 405 | 406 | TermState.prototype.prevLine = function prevLine() { 407 | if (this.cursor[1] > 0) this.mvCursor(0, -1); 408 | else this.scroll(-1); 409 | return this; 410 | }; 411 | 412 | TermState.prototype.nextLine = function nextLine() { 413 | if (this.cursor[1] < this.rows - 1) this.mvCursor(0, +1); 414 | else this.scroll(+1); 415 | return this; 416 | }; 417 | 418 | TermState.prototype.saveCursor = function saveCursor() { 419 | this.savedCursor = this.getCursor(); 420 | return this; 421 | }; 422 | 423 | TermState.prototype.restoreCursor = function restoreCursor() { 424 | this.cursor = this.savedCursor; 425 | return this.setCursor(); 426 | }; 427 | 428 | TermState.prototype.insertBlank = function insertBlank(n) { 429 | var str = ""; 430 | while (str.length < n) str += " "; 431 | return this._writeChunk(this.cursor, str, true); 432 | }; 433 | 434 | TermState.prototype.eraseCharacters = function eraseCharacters(n) { 435 | var str = ""; 436 | while (str.length < n) str += " "; 437 | return this._writeChunk(this.cursor, str, false); 438 | }; 439 | 440 | TermState.prototype.setScrollRegion = function setScrollRegion(n, m) { 441 | //TODO 442 | return this; 443 | }; 444 | 445 | TermState.prototype.switchBuffer = function switchBuffer(alt) { 446 | if (this.alt !== alt) { 447 | this.scroll(this.lines.length); 448 | this.alt = alt; 449 | } 450 | return this; 451 | }; 452 | 453 | TermState.prototype.getBufferRowCount = function getBufferRowCount() { 454 | return this.lines.length; 455 | }; 456 | 457 | 458 | /** 459 | * moves Cursor forward or backward a specified amount of tabs 460 | * @param n {number} - number of tabs to move. <0 moves backward, >0 moves 461 | * forward 462 | */ 463 | TermState.prototype.mvTab = function(n) { 464 | var x = this.getCursor()[0]; 465 | var tabMax = this._tabs[this._tabs.length - 1] || 0; 466 | var positive = n > 0; 467 | n = Math.abs(n); 468 | while(n !== 0 && x > 0 && x < this.columns-1) { 469 | x += positive ? 1 : -1; 470 | if(this._tabs.indexOf(x) != -1 || (x > tabMax && x % 8 === 0)) 471 | n--; 472 | } 473 | this.setCursor(x); 474 | }; 475 | 476 | /** 477 | * set tab at specified position 478 | * @param pos {number} - position to set a tab at 479 | */ 480 | TermState.prototype.setTab = function(pos) { 481 | // Set the default to current cursor if no tab position is specified 482 | if(pos === undefined) { 483 | pos = this.getCursor()[0]; 484 | } 485 | // Only add the tab position if it is not there already 486 | if (this._tabs.indexOf(pos) != -1) { 487 | this._tabs.push(pos); 488 | this._tabs.sort(); 489 | } 490 | }; 491 | 492 | /** 493 | * remove a tab 494 | * @param pos {number} - position to remove a tab. Do nothing if the tab isn't 495 | * set at this position 496 | */ 497 | TermState.prototype.removeTab = function(pos) { 498 | var i, tabs = this._tabs; 499 | for(i = 0; i < tabs.length && tabs[i] !== pos; i++); 500 | tabs.splice(i, 1); 501 | }; 502 | 503 | /** 504 | * removes a tab at a given index 505 | * @params n {number} - can be one of the following 506 | *