/** * A Tab that contains a REPL. * The REPL allows the user to write snippets of python code and see the results of evaluating * them. In particular, the REPL has access to the usercode module, so they can see the results * of quick operations on their data. * The REPL supports evaluation of code, removal of lines from history, and re-computation * and editing of older lines. */ var kd = require('../lib/koDom'); var ko = require('knockout'); var dom = require('../lib/dom'); var Base = require('./Base'); var commands = require('./commands'); var NEW_LINE = -1; /** * Hard tab used instead of soft tabs, as soft-tabs would require a lot of additional * editor logic (partial-width tabs, backspacing a tab, ...) for * which we may want to eventually use a 3rd-party library for in addition to syntax highlighting, etc */ var INDENT_STR = "\t"; function REPLTab(gristDoc) { Base.call(this, gristDoc); this.replHist = gristDoc.docModel.replHist.createAllRowsModel("id"); this.docData = gristDoc.docData; this.editingIndex = ko.observable(null); this.histIndex = ko.observable(this.replHist.peekLength); this.editorActive = ko.observable(false); this.numLines = ko.observable(0); this.row = null; this._contentSizer = ko.observable(''); this._originalValue = ''; this._textInput = null; this.commandGroup = this.autoDispose(commands.createGroup( REPLTab.replCommands, this, this.editorActive)); } Base.setBaseFor(REPLTab); /** * Editor commands for the cellEditor in the REPL Tab * TODO: Using the command group, distinguish between "on enter" saves and "on blur" saves * So that we can give up focus on blur */ REPLTab.replCommands = { // TODO: GridView commands are activated more recently after startup. fieldEditSave: function() { if (!this._textInput || !this.editorActive() || !this._textInput.value.trim() && this.editingIndex() === NEW_LINE) { return; } // TODO: Scroll pane does not automatically scroll down on save. var self = this; this.save() .then(function(success) { if (success) { self.editingIndex(NEW_LINE); self.clear(); // Refresh the history index. self.histIndex(self.replHist.peekLength); } else { self.write("\n"); // Since focus is staying in the current input, increment lines. self.numLines(self.numLines.peek()+1); } }); }, fieldEditCancel: function() { this.clear(); this.editingIndex(NEW_LINE); }, nextField: function() { // In this case, 'nextField' (Tab) inserts a tab. this.write(INDENT_STR); }, historyPrevious: function() { // Fills the editor with the code previously entered. if (this.editingIndex() === NEW_LINE) { this.writePrev(); } }, historyNext: function() { // Fills the editor with the code entered after the current code. if (this.editingIndex() === NEW_LINE) { this.writeNext(); } } }; /** * Sends the entered code as an EvalCode Useraction. * @param {Function} callback - Is called with a single argument 'success' indicating * whether the save was successful. */ REPLTab.prototype.save = function(callback) { if (!this._textInput.value.trim()) { // If its text is cleared, remove history item. var currentEditIndex = this.editingIndex(); this.histIndex(this.replHist.peekLength - 1); this.editorActive(false); return this.docData.sendAction(["RemoveRecord", "_grist_REPL_Hist", currentEditIndex]); } else { // If something is entered, save value. var rowId = this.row ? this.row.id() : null; return this.docData.sendAction(["EvalCode", this._textInput.value, rowId]); } }; // Builds object with REPLTab dom builder and settings for the sidepane. REPLTab.prototype.buildConfigDomObj = function() { return [{ 'buildDom': this.buildDom.bind(this), 'keywords': ['repl', 'console', 'python', 'code', 'terminal'] }]; }; REPLTab.prototype.buildDom = function() { var self = this; return dom('div', kd.foreach(this.replHist, function(replLine) { return dom('div.repl-container', dom('div.repl-text_line', kd.scope(function() { return self.editingIndex() === replLine.id(); }, function(isEditing) { if (isEditing) { return dom('div.field.repl-field', kd.scope(self.numLines, function(numLines) { return self.buildPointerGroup(numLines); }), self.attachEditorDom(replLine)); } else { var numLines = replLine.code().trim().split('\n').length; return dom('div.repl-field', dom.on('click', function() { // TODO: Flickering occurs on click for multiline code segments. self.editingIndex(replLine.id()); self.focus(); }), self.buildPointerGroup(numLines), dom('div.repl-text', kd.text(replLine.code) ) ); } } ), dom('div.erase_line_button.unselectable', dom.on('click', function() { self.histIndex(self.replHist.peekLength - 1); return self.docData.sendAction( ["RemoveRecord", "_grist_REPL_Hist", replLine.id()] ); }), '\u2A09'), dom('div.re-eval_line_button.unselectable', dom.on('click', function() { return self.docData.sendAction( ["EvalCode", replLine.code(), replLine.id()] ); }), '\u27f3') // 'refresh' symbol ), kd.maybe(replLine.outputText, function() { return dom('div.repl-text.repl-output', kd.text(replLine.outputText)); }), kd.maybe(replLine.errorText, function() { return dom('div.repl-text.repl-error', kd.text(replLine.errorText)); }) ); }), // Special bottom editor which sends actions to add new records to the REPL hist. dom('div.repl-newline', dom.on('click', function() { self.editingIndex(NEW_LINE); self.focus(); }), dom('div.field.repl-field', kd.scope(self.numLines, function(numLines) { return self.buildPointerGroup(self.editingIndex() === NEW_LINE ? numLines : 1); }), kd.maybe(ko.pureComputed(function() { return self.editingIndex() === NEW_LINE; }), function() { return self.attachEditorDom(null); } ) ) ) ); }; /** * Builds the set of pointers to the left of the code * @param {String} code - The code for which the pointer group is to be built. */ REPLTab.prototype.buildPointerGroup = function(numLines) { var pointers = []; for (var i = 0; i < numLines; i++) { pointers.push(dom('div.pointer', i ? '...' : '>>>')); } return dom('div.pointer_group.unselectable', pointers); }; REPLTab.prototype.buildEditorDom = function() { var self = this; return dom('div.repl-cursor_editor', dom('div.repl-content_measure.formula-text', kd.text(this._contentSizer)), function() { self._textInput = dom('textarea.repl-text_editor.formula-text', kd.value(self.row ? self.row.code() : ""), dom.on('focus', function() { self.numLines(this.value.split('\n').length); }), dom.on('blur', function() { if (!this._textInput || !this.editorActive()) { return; } self.save() .then(function(success) { if (success) { // If editing a new line, clear it to start fresh. if (self.editingIndex() === NEW_LINE) { self.clear(); } // Refresh the history index. self.histIndex(self.replHist.peekLength); } else { self.write("\n"); } self.editorActive(false); }); }), //Resizes the textbox whenever user writes in it. dom.on('input', function() { self.numLines(this.value.split('\n').length); self.resizeElem(); }), dom.defer(function(elem) { self.resizeElem(); elem.focus(); // Set the cursor at the end. var elemLen = elem.value.length; elem.selectionStart = elemLen; elem.selectionEnd = elemLen; }), dom.on('mouseup mousedown click', function(event) { event.stopPropagation(); }), self.commandGroup.attach() ); return self._textInput; } ); }; /** * This function measures a hidden div with the same value as the textarea being edited and then resizes the textarea to match. */ REPLTab.prototype.resizeElem = function() { // \u200B is a zero-width space; it is used so the textbox will expand vertically // on newlines, but it does not add any width the string this._contentSizer(this._textInput.value + '\u200B'); var rect = this._textInput.parentNode.childNodes[0].getBoundingClientRect(); //Allows form to expand passed its container div. this._textInput.style.width = Math.ceil(rect.width) + 'px'; this._textInput.style.height = Math.ceil(rect.height) + 'px'; }; /** * Appends text to the contents being edited */ REPLTab.prototype.write = function(text) { this._textInput.value += text; this.resizeElem(); }; /** * Clears both the current text and any memory of text in the currently edited cell. */ REPLTab.prototype.clear = function() { this._textInput.value = ""; this._orignalValue = ""; this.numLines(1); this.resizeElem(); }; /** * Restores focus to the most recent input. */ REPLTab.prototype.focus = function() { if (this._textInput) { this._textInput.focus(); this.editorActive(true); } }; /** * Writes the code entered before the current code to the input. */ REPLTab.prototype.writePrev = function() { this.histIndex(Math.max(this.histIndex.peek() - 1, 0)); this.clear(); if (this.replHist.at(this.histIndex.peek())) { this.write(this.replHist.at(this.histIndex.peek()).code()); } }; /** * Writes the code entered after the current code to the input. */ REPLTab.prototype.writeNext = function() { this.histIndex(Math.min(this.histIndex() + 1, this.replHist.peekLength)); this.clear(); if (this.histIndex.peek() < this.replHist.peekLength) { this.write(this.replHist.at(this.histIndex.peek()).code()); } }; /** * This function is called in the DOM element where an editor is desired. * It attaches to as a child of that element with that elements value as default or whatever is set as an override value. */ REPLTab.prototype.attachEditorDom = function(row) { var self = this; self.row = row; self._originalValue = self.row ? self.row.code() : ""; return self.buildEditorDom(); }; module.exports = REPLTab;