/* jslint node: true */ 'use strict'; var View = require('./view.js').View; var miscUtil = require('./misc_util.js'); var strUtil = require('./string_util.js'); var ansi = require('./ansi_term.js'); var assert = require('assert'); var _ = require('lodash'); var GapBuffer = require('gapbuffer').GapBuffer; // // Notes // * options.tabSize can be used to resolve \t // * See https://github.com/dominictarr/hipster/issues/15 about insert/delete lines // // Blessed // insertLine: CSR(top, bottom) + CUP(y, 0) + IL(1) + CSR(0, height) // deleteLine: CSR(top, bottom) + CUP(y, 0) + DL(1) + CSR(0, height) // Quick Ansi -- update only what was changed: // https://github.com/dominictarr/quickansi // // This thread is awesome: // https://github.com/dominictarr/hipster/issues/15 // // See Atom's implementations // Newer TextDocument // https://github.com/atom/text-document // // Older TextBuffer // http://www.oscon.com/oscon2014/public/schedule/detail/37593 // // Span Skip List could be used for mappings of rows/cols (display) to // character offsets in a buffer // https://github.com/atom/span-skip-list // // Buffer: Actual text buffer // Transform: Display of soft wrap & tab expansion (e.g. tab -> ' ' * tabWidth) // // // General Design // // * Take any existing input & word wrap into lines[] preserving // formatting characters. // * When drawing, formatting characters are processed but not shown // or processed directly in many cases. E.g., \n is processed but simply // causes us to go to our "next line" visibly. // * Empty/blank lines = \n // exports.MultiLineEditTextView = MultiLineEditTextView; // // Some resources & comparisons // // Enthral @ https://github.com/M-griffin/Enthral/blob/master/src/msg_fse.cpp // * Tabs are ignored // * Preview/reading mode processes colors, otherwise just text (e.g. editor) // // x84 @ https://github.com/jquast/x84/blob/master/x84/bbs/editor.py // // Syncronet // // // Projects of use/interest: // // https://github.com/atom/text-buffer // http://danieltao.com/lazy.js/ // http://www.jbox.dk/downloads/edit.c // https://github.com/slap-editor/slap // https://github.com/chjj/blessed // // need self.skipTabs(dir): if pos='\t', skip ahead (in dir) until reg char. This can be used @ up, left, right, down function MultiLineEditTextView(options) { if(!_.isBoolean(options.acceptsFocus)) { options.acceptsFocus = true; } if(!_.isBoolean(this.acceptsInput)) { options.acceptsInput = true; } View.call(this, options); // // defualt tabWidth is 8 // See the following: // * http://www.ansi-bbs.org/ansi-bbs2/control_chars/ // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt // this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 8; var self = this; this.renderBuffer = []; this.textBuffer = new GapBuffer(1024); this.lines = []; // a given line is text...until EOL this.topLineIndex = 0; this.cursorPos = { row : 0, col : 0 }; // relative to view window this.renderStartIndex = 0; this.getTabString = function() { return new Array(self.tabWidth).join(' '); }; this.getRenderLine = function(line) { // :TODO: fix tabbing here line = line.replace(self.getReplaceTabsRegExp(), self.getTabString()).replace(/\n/g, ''); var remain = self.dimens.width - line.length; if(remain > 0) { line += new Array(remain).join(' '); } return line; }; this.redrawViewableText = function() { var row = self.position.row; var bottom = row + self.dimens.height; var i = self.topLineIndex; self.client.term.write(self.getSGR()); while(i < self.renderBuffer.length && row < bottom) { self.client.term.write(ansi.goto(row, this.position.col)); self.client.term.write(self.getRenderLine(self.renderBuffer[i])); ++row; ++i; } }; this.wordWrap = function(line) { // // Other implementations: // * http://blog.macromates.com/2006/wrapping-text-with-regular-expressions/ // * http://james.padolsey.com/snippets/wordwrap-for-javascript/ // * http://phpjs.org/functions/wordwrap/ // * https://github.com/jonschlinkert/word-wrap // var re = new RegExp( '.{1,' + self.dimens.width + '}(\\s+|$)|\\S+?(\\s+|$)', 'g'); return line.match(re) || []; }; this.wordWrap3 = function(line, width) { var re = new RegExp('.{1,' + width + '}(\\s+|$)|\\S+?(\\s+|$)', 'g'); return line.replace(/\t/g, new Array(self.tabWidth).join('\t')).match(re) || []; }; this.updateRenderBuffer = function() { // // We can estimate what is visible: // * Starting point = start of buffer or previous LF from where we were previously // * Ending point = start + width * height (max chars possible) // If this system is kept, this can be optimized as per above // self.renderBuffer = []; // :TODO: optimize this with asArray() taking the slice information var lines = self.textBuffer.asArray()//.slice(self.renderStartIndex, self.renderStartIndex + self.dimens.width * self.dimens.height) .join('') .split(/\r\n|\n|\r/g); var maxLines = self.dimens.height - self.position.row; for(var i = 0; i < lines.length && self.renderBuffer.length < maxLines; ++i) { if(0 === lines[i].length) { self.renderBuffer.push(''); } else { Array.prototype.push.apply(self.renderBuffer, self.wordWrap3(lines[i] + '\n', self.dimens.width)); } } }; this.getReplaceTabsRegExp = function() { return new RegExp('\\t{' + (self.tabWidth - 1) + '}', 'g'); }; this.getTextBufferPosition = function(row, col) { var replaceTabsRe = self.getReplaceTabsRegExp(); var pos = 0; for(var r = 0; r < row; ++r) { if(self.renderBuffer[r].length > 0) { pos += self.renderBuffer[r].replace(replaceTabsRe, '\t').length; } else { pos += 1; } } pos += self.renderBuffer[row] .slice(0, Math.min(col, self.dimens.width)) .replace(replaceTabsRe, '\t') .length; return pos; }; this.getLineTextLength = function(row) { return self.renderBuffer[row].replace(self.getReplaceTabsRegExp(), '\t').replace(/\n/g, '').length; //return self.renderBuffer[row].replace(/\n/g, '').length; }; this.getRenderTextLength = function(row) { return self.renderBuffer[row].replace(/\n/g, '').length; }; // :TODO: this name makes no sense... this.getLineTextLengthToColumn = function(row, col) { return self.renderBuffer[row].replace(self.getReplaceTabsRegExp(), '\t').replace(/\n/g, '').slice(0, col).length; }; this.getEndOfLineColumn = function(row) { if(!_.isNumber(row)) { row = self.cursorPos.row; } return self.getLineTextLength(row); }; this.getAbsolutePosition = function(row, col) { if(!_.isNumber(row)) { row = self.cursorPos.row; } if(!_.isNumber(col)) { col = self.cursorPos.col; } return { row : self.position.row + row, col : self.position.col + col }; }; // :TODO: rename this to show it is the *buffer* this.getCharAtCursorPosition = function() { var pos = self.getTextBufferPosition(self.cursorPos.row, self.cursorPos.col); return self.textBuffer.get(pos); }; this.getRenderCharAtRowAndColumn = function(row, col) { return self.renderBuffer[row][col]; }; this.getRenderCharAtCursorPosition = function() { return self.getRenderCharAtRowAndColumn(self.cursorPos.row, self.cursorPos.col); }; this.getRemainingRowsFromCurrent = function() { return Math.min(self.dimens.height, self.renderBuffer.length) - (self.cursorPos.row + 1); }; this.moveCursorTo = function(row, col) { var absPos = self.getAbsolutePosition(row, col); self.client.term.write(ansi.goto(absPos.row, absPos.col)); }; this.scrollUp = function(count) { }; this.scrollDown = function(count) { }; this.cursorMoveJumpTab = function(cursorDir) { if('\t' !== self.getRenderCharAtCursorPosition()) { return; // nothing to do } // // A few scenarios: // * Cursor just moved up or down and we got dumped in the middle of a tab sequence. // :TODO: document: Jump to nearest tab right/left. This needs some more research // * Cursor moved left or right: We should be on the first \t in either direction & need to jump // * Tabs may expand to start/end of line -- in this case we should move to the next line // // Example tab sequence when up/down (tabSize=8) // Actual: Hello\tWorld! // Render: Hello\t\t\t\t\t\t\t\tWorld! // ^-- cursor up from here // switch(cursorDir) { case 'left' : self.cursorPos.col -= (self.tabWidth - 1); if(self.cursorPos.col <= 0) { self.cursorToEndOfPreviousLine(); } else { self.client.term.write(ansi.left(self.tabWidth - 1)); } break; case 'right' : self.cursorPos.col += (self.tabWidth - 1); if(self.cursorPos.col >= self.dimens.width) { self.cursorToStartOfNextLine(); } else { self.client.term.write(ansi.right(self.tabWidth - 1)); } break; case 'up' : case 'down' : // // We're going to move right, but we need to know where we're at in // in the render buffer expanded tabs // // :TODO: This is not right -- we need to move to *nearest*. Research how // sublime/etc. treat this var col = self.cursorPos.col; var prevTabs = 0; while('\t' === self.getRenderCharAtRowAndColumn(self.cursorPos.row, col--)) { prevTabs++; } //console.log('prevTabs: ' + prevTabs) var adjust = self.tabWidth - prevTabs; self.cursorPos.col += adjust; self.client.term.write(ansi.right(adjust)); break; } }; this.adjustColumnToEndOfLine = function() { var eolColumn = self.getEndOfLineColumn(); if(self.cursorPos.col > eolColumn) { self.cursorPos.col = eolColumn; var absPos = self.getAbsolutePosition(self.cursorPos.row, eolColumn); self.client.term.write(ansi.goto(absPos.row, absPos.col)); } }; this.cursorUp = function() { if(self.cursorPos.row > 0) { self.cursorPos.row--; self.client.term.write(ansi.up()); self.cursorMoveJumpTab('up'); } else if(self.topLineIndex > 0) { // :TODO: scroll up if possible to do so } // adjust to EOL position if needed self.adjustColumnToEndOfLine(); }; this.cursorDown = function() { if(self.getRemainingRowsFromCurrent() > 0) { self.cursorPos.row++; self.client.term.write(ansi.down()); self.cursorMoveJumpTab('down'); } else { // :TODO: scroll if possible } // adjust to EOL position if needed self.adjustColumnToEndOfLine(); }; this.cursorLeft = function() { if(self.cursorPos.col > 0) { self.cursorPos.col--; self.client.term.write(ansi.left()); self.cursorMoveJumpTab('left'); } else { self.cursorToEndOfPreviousLine(); } }; this.cursorRight = function() { var rowVisibleLen = self.renderBuffer[self.cursorPos.row].replace(/\n/g, '').length; var max = Math.min(self.dimens.width, rowVisibleLen - 1);//selfself.getLineTextLength(self.cursorPos.row) - 1); console.log('self.dimens.width: ' + self.dimens.width + ' / lineLength: ' + (self.getLineTextLength(self.cursorPos.row) - 1)) if(self.cursorPos.col < max) { self.cursorPos.col++; self.client.term.write(ansi.right()); // make tab adjustment if necessary self.cursorMoveJumpTab('right'); } else { self.cursorToStartOfNextLine(); } }; this.cursorToEndOfPreviousLine = function() { if(self.cursorPos.row > 0) { self.cursorPos.row--; self.cursorPos.col = self.getRenderTextLength(self.cursorPos.row) - 1; self.moveCursorTo(self.cursorPos.row, self.cursorPos.col); } else { // can we scroll??!!! } }; this.cursorToStartOfNextLine = function() { if(self.getRemainingRowsFromCurrent() > 0) { self.cursorPos.row++; self.cursorPos.col = 0; self.moveCursorTo(self.cursorPos.row, self.cursorPos.col); } else { // :TODO: can we scroll?? } }; this.getLineIndex = function() { return self.topLineIndex + self.cursorPos.row; }; this.insertCharacterAtCurrentPosition = function(c) { var pos = self.getTextBufferPosition(self.cursorPos.row, self.cursorPos.col); self.cursorPos.col++; if(self.cursorPos.col >= self.dimens.width) { self.cursorToStartOfNextLine(); } self.client.term.write(c); self.textBuffer.insert(pos, c); }; } require('util').inherits(MultiLineEditTextView, View); MultiLineEditTextView.prototype.setPosition = function(pos) { MultiLineEditTextView.super_.prototype.setPosition.call(this, pos); }; MultiLineEditTextView.prototype.redraw = function() { MultiLineEditTextView.super_.prototype.redraw.call(this); this.redrawViewableText(); //this.client.term.write(this.text); }; MultiLineEditTextView.prototype.setFocus = function(focused) { MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); this.moveCursorTo(this.cursorPos.row, this.cursorPos.col); this.client.term.write(this.getSGR()); }; MultiLineEditTextView.prototype.setText = function(text) { //this.cursorPos.row = this.position.row + this.dimens.height; //this.lines = this.wordWrap(text); if(this.textBuffer.length > 0) { // :TODO: work around GapBuffer bug: if it's already empty this will cause gapEnd to be undefined this.textBuffer.clear(); } //this.textBuffer.insertAll(0, text); text = text.replace(/\b/g, ''); this.textBuffer.insertAll(0, text); this.updateRenderBuffer(); console.log(this.renderBuffer) /* var idx = this.getTextBufferPosition(4, 0); for(var i = idx; i < idx + 4; ++i) { console.log(i + ' = "' + this.textBuffer.asArray()[i] + '"'); } this.cursorPos.row = 15; this.cursorPos.col = 0; */ this.cursorPos.row = 14; this.cursorPos.col = 0; }; MultiLineEditTextView.prototype.onKeyPress = function(key, isSpecial) { if(isSpecial) { return; } assert(1 === key.length); this.insertCharacterAtCurrentPosition(key); this.updateRenderBuffer(); // :TODO: is save/restore supported enough? Should we do it ourselves? this.client.term.write(ansi.savePos()); // :TODO: Just draw from position onward this.redraw(); this.client.term.write(ansi.restorePos()); MultiLineEditTextView.super_.prototype.onKeyPress.call(this, key, isSpecial); }; MultiLineEditTextView.prototype.onSpecialKeyPress = function(keyName) { if(this.isSpecialKeyMapped('up', keyName)) { this.cursorUp(); } else if(this.isSpecialKeyMapped('down', keyName)) { this.cursorDown(); } else if(this.isSpecialKeyMapped('left', keyName)) { this.cursorLeft(); } else if(this.isSpecialKeyMapped('right', keyName)) { this.cursorRight(); } console.log( 'row=' + this.cursorPos.row + ' / col=' + this.cursorPos.col + ' / abs=' + JSON.stringify(this.getAbsolutePosition()) + ': ' + this.getCharAtCursorPosition() + '( ' + this.getCharAtCursorPosition().charCodeAt(0) + ')') MultiLineEditTextView.super_.prototype.onSpecialKeyPress.call(this, keyName); };