/* jslint node: true */ 'use strict'; const View = require('./view.js').View; const strUtil = require('./string_util.js'); const ansi = require('./ansi_term.js'); const colorCodes = require('./color_codes.js'); const wordWrapText = require('./word_wrap.js').wordWrapText; const ansiPrep = require('./ansi_prep.js'); const assert = require('assert'); const _ = require('lodash'); // :TODO: Determine CTRL-* keys for various things // See http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt // http://wiki.synchro.net/howto:editor:slyedit#edit_mode // http://sublime-text-unofficial-documentation.readthedocs.org/en/latest/reference/keyboard_shortcuts_win.html /* Mystic [^B] Reformat Paragraph [^O] Show this help file [^I] Insert tab space [^Q] Enter quote mode [^K] Cut current line of text [^V] Toggle insert/overwrite [^U] Paste previously cut text [^Y] Delete current line BASIC MOVEMENT COMMANDS UP/^E LEFT/^S PGUP/^R HOME/^F DOWN/^X RIGHT/^D PGDN/^C END/^G */ // // Some other interesting implementations, resources, etc. // // Editors - BBS // * https://github.com/M-griffin/Enthral/blob/master/src/msg_fse.cpp // // // Editors - Other // * http://joe-editor.sourceforge.net/ // * http://www.jbox.dk/downloads/edit.c // * https://github.com/dominictarr/hipster // // Implementations - Word Wrap // * https://github.com/protomouse/synchronet/blob/93b01c55b3102ebc3c4f4793c3a45b8c13d0dc2a/src/sbbs3/wordwrap.c // // Misc notes // * https://github.com/dominictarr/hipster/issues/15 (Deleting lines/etc.) // // 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 // // To-Do // // * Index pos % for emit scroll events // * Some of this shoudl be async'd where there is lots of processing (e.g. word wrap) // * Fix backspace when col=0 (e.g. bs to prev line) // * Add back word delete // * const SPECIAL_KEY_MAP_DEFAULT = { 'line feed' : [ 'return' ], exit : [ 'esc' ], backspace : [ 'backspace' ], delete : [ 'del' ], tab : [ 'tab' ], up : [ 'up arrow' ], down : [ 'down arrow' ], end : [ 'end' ], home : [ 'home' ], left : [ 'left arrow' ], right : [ 'right arrow' ], 'delete line' : [ 'ctrl + y' ], 'page up' : [ 'page up' ], 'page down' : [ 'page down' ], insert : [ 'insert', 'ctrl + v' ], }; exports.MultiLineEditTextView = MultiLineEditTextView; function MultiLineEditTextView(options) { if(!_.isBoolean(options.acceptsFocus)) { options.acceptsFocus = true; } if(!_.isBoolean(this.acceptsInput)) { options.acceptsInput = true; } if(!_.isObject(options.specialKeyMap)) { options.specialKeyMap = SPECIAL_KEY_MAP_DEFAULT; } View.call(this, options); var self = this; // // ANSI seems to want tabs to default to 8 characters. See the following: // * http://www.ansi-bbs.org/ansi-bbs2/control_chars/ // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt // // This seems overkill though, so let's default to 4 :) // :TODO: what shoudl this really be? Maybe 8 is OK // this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 4; this.textLines = [ ]; this.topVisibleIndex = 0; this.mode = options.mode || 'edit'; // edit | preview | read-only if ('preview' === this.mode) { this.autoScroll = options.autoScroll || true; this.tabSwitchesView = true; } else { this.autoScroll = options.autoScroll || false; this.tabSwitchesView = options.tabSwitchesView || false; } // // cursorPos represents zero-based row, col positions // within the editor itself // this.cursorPos = { col : 0, row : 0 }; this.getSGRFor = function(sgrFor) { return { text : self.getSGR(), }[sgrFor] || self.getSGR(); }; this.isEditMode = function() { return 'edit' === self.mode; }; this.isPreviewMode = function() { return 'preview' === self.mode; }; // :TODO: Most of the calls to this could be avoided via incrementRow(), decrementRow() that keeps track or such this.getTextLinesIndex = function(row) { if(!_.isNumber(row)) { row = self.cursorPos.row; } var index = self.topVisibleIndex + row; return index; }; this.getRemainingLinesBelowRow = function(row) { if(!_.isNumber(row)) { row = self.cursorPos.row; } return self.textLines.length - (self.topVisibleIndex + row) - 1; }; this.getNextEndOfLineIndex = function(startIndex) { for(var i = startIndex; i < self.textLines.length; i++) { if(self.textLines[i].eol) { return i; } } return self.textLines.length; }; this.toggleTextCursor = function(action) { self.client.term.rawWrite(`${self.getSGRFor('text')}${'hide' === action ? ansi.hideCursor() : ansi.showCursor()}`); }; this.redrawRows = function(startRow, endRow) { self.toggleTextCursor('hide'); const startIndex = self.getTextLinesIndex(startRow); const endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length); const absPos = self.getAbsolutePosition(startRow, 0); for(let i = startIndex; i < endIndex; ++i) { //${self.getSGRFor('text')} self.client.term.write( `${ansi.goto(absPos.row++, absPos.col)}${self.getRenderText(i)}`, false // convertLineFeeds ); } self.toggleTextCursor('show'); return absPos.row - self.position.row; // row we ended on }; this.eraseRows = function(startRow, endRow) { self.toggleTextCursor('hide'); const absPos = self.getAbsolutePosition(startRow, 0); const absPosEnd = self.getAbsolutePosition(endRow, 0); const eraseFiller = ' '.repeat(self.dimens.width);//new Array(self.dimens.width).join(' '); while(absPos.row < absPosEnd.row) { self.client.term.write( `${ansi.goto(absPos.row++, absPos.col)}${eraseFiller}`, false // convertLineFeeds ); } self.toggleTextCursor('show'); }; this.redrawVisibleArea = function() { assert(self.topVisibleIndex <= self.textLines.length); const lastRow = self.redrawRows(0, self.dimens.height); self.eraseRows(lastRow, self.dimens.height); /* // :TOOD: create eraseRows(startRow, endRow) if(lastRow < self.dimens.height) { var absPos = self.getAbsolutePosition(lastRow, 0); var empty = new Array(self.dimens.width).join(' '); while(lastRow++ < self.dimens.height) { self.client.term.write(ansi.goto(absPos.row++, absPos.col)); self.client.term.write(empty); } } */ }; this.getVisibleText = function(index) { if(!_.isNumber(index)) { index = self.getTextLinesIndex(); } return self.textLines[index].text.replace(/\t/g, ' '); }; this.getText = function(index) { if(!_.isNumber(index)) { index = self.getTextLinesIndex(); } return self.textLines.length > index ? self.textLines[index].text : ''; }; this.getTextLength = function(index) { if(!_.isNumber(index)) { index = self.getTextLinesIndex(); } return self.textLines.length > index ? self.textLines[index].text.length : 0; }; this.getCharacter = function(index, col) { if(!_.isNumber(col)) { col = self.cursorPos.col; } return self.getText(index).charAt(col); }; this.isTab = function(index, col) { return '\t' === self.getCharacter(index, col); }; this.getTextEndOfLineColumn = function(index) { return Math.max(0, self.getTextLength(index)); }; this.getRenderText = function(index) { let text = self.getVisibleText(index); const remain = self.dimens.width - text.length; if(remain > 0) { text += ' '.repeat(remain + 1); // text += new Array(remain + 1).join(' '); } return text; }; this.getTextLines = function(startIndex, endIndex) { var lines; if(startIndex === endIndex) { lines = [ self.textLines[startIndex] ]; } else { lines = self.textLines.slice(startIndex, endIndex + 1); // "slice extracts up to but not including end." } return lines; }; this.getOutputText = function(startIndex, endIndex, eolMarker, options) { const lines = self.getTextLines(startIndex, endIndex); let text = ''; const re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); lines.forEach(line => { text += line.text.replace(re, '\t'); if(options.forceLineTerms || (eolMarker && line.eol)) { text += eolMarker; } }); return text; }; this.getContiguousText = function(startIndex, endIndex, includeEol) { var lines = self.getTextLines(startIndex, endIndex); var text = ''; for(var i = 0; i < lines.length; ++i) { text += lines[i].text; if(includeEol && lines[i].eol) { text += '\n'; } } return text; }; this.replaceCharacterInText = function(c, index, col) { self.textLines[index].text = strUtil.replaceAt( self.textLines[index].text, col, c); }; /* this.editTextAtPosition = function(editAction, text, index, col) { switch(editAction) { case 'insert' : self.insertCharactersInText(text, index, col); break; case 'deleteForward' : break; case 'deleteBack' : break; case 'replace' : break; } }; */ this.updateTextWordWrap = function(index) { var nextEolIndex = self.getNextEndOfLineIndex(index); var wrapped = self.wordWrapSingleLine(self.getContiguousText(index, nextEolIndex), 'tabsIntact'); var newLines = wrapped.wrapped; for(var i = 0; i < newLines.length; ++i) { newLines[i] = { text : newLines[i] }; } newLines[newLines.length - 1].eol = true; Array.prototype.splice.apply( self.textLines, [ index, (nextEolIndex - index) + 1 ].concat(newLines)); return wrapped.firstWrapRange; }; this.removeCharactersFromText = function(index, col, operation, count) { if('right' === operation) { self.textLines[index].text = self.textLines[index].text.slice(col, count) + self.textLines[index].text.slice(col + count); self.cursorPos.col -= count; self.updateTextWordWrap(index); self.redrawRows(self.cursorPos.row, self.dimens.height); if(0 === self.textLines[index].text) { } else { self.redrawRows(self.cursorPos.row, self.dimens.height); } } else if ('backspace' === operation) { // :TODO: method for splicing text self.textLines[index].text = self.textLines[index].text.slice(0, col - (count - 1)) + self.textLines[index].text.slice(col + 1); self.cursorPos.col -= (count - 1); self.updateTextWordWrap(index); self.redrawRows(self.cursorPos.row, self.dimens.height); self.moveClientCursorToCursorPos(); } else if('delete line' === operation) { // // Delete a visible line. Note that this is *not* the "physical" line, or // 1:n entries up to eol! This is to keep consistency with home/end, and // some other text editors such as nano. Sublime for example want to // treat all of these things using the physical approach, but this seems // a bit odd in this context. // var isLastLine = (index === self.textLines.length - 1); var hadEol = self.textLines[index].eol; self.textLines.splice(index, 1); if(hadEol && self.textLines.length > index && !self.textLines[index].eol) { self.textLines[index].eol = true; } // // Create a empty edit buffer if necessary // :TODO: Make this a method if(self.textLines.length < 1) { self.textLines = [ { text : '', eol : true } ]; isLastLine = false; // resetting } self.cursorPos.col = 0; var lastRow = self.redrawRows(self.cursorPos.row, self.dimens.height); self.eraseRows(lastRow, self.dimens.height); // // If we just deleted the last line in the buffer, move up // if(isLastLine) { self.cursorEndOfPreviousLine(); } else { self.moveClientCursorToCursorPos(); } } }; this.insertCharactersInText = function(c, index, col) { self.textLines[index].text = [ self.textLines[index].text.slice(0, col), c, self.textLines[index].text.slice(col) ].join(''); //self.cursorPos.col++; self.cursorPos.col += c.length; var cursorOffset; var absPos; if(self.getTextLength(index) > self.dimens.width) { // // Update word wrapping and |cursorOffset| if the cursor // was within the bounds of the wrapped text // var lastCol = self.cursorPos.col - c.length; var firstWrapRange = self.updateTextWordWrap(index); if(lastCol >= firstWrapRange.start && lastCol <= firstWrapRange.end) { cursorOffset = self.cursorPos.col - firstWrapRange.start; } // redraw from current row to end of visible area self.redrawRows(self.cursorPos.row, self.dimens.height); if(!_.isUndefined(cursorOffset)) { self.cursorBeginOfNextLine(); self.cursorPos.col += cursorOffset; self.client.term.rawWrite(ansi.right(cursorOffset)); } else { self.moveClientCursorToCursorPos(); } } else { // // We must only redraw from col -> end of current visible line // absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); self.client.term.write( ansi.hideCursor() + self.getSGRFor('text') + self.getRenderText(index).slice(self.cursorPos.col - c.length) + ansi.goto(absPos.row, absPos.col) + ansi.showCursor(), false ); } }; this.getRemainingTabWidth = function(col) { if(!_.isNumber(col)) { col = self.cursorPos.col; } return self.tabWidth - (col % self.tabWidth); }; this.calculateTabStops = function() { self.tabStops = [ 0 ]; var col = 0; while(col < self.dimens.width) { col += self.getRemainingTabWidth(col); self.tabStops.push(col); } }; this.getNextTabStop = function(col) { var i = self.tabStops.length; while(self.tabStops[--i] > col); return self.tabStops[++i]; }; this.getPrevTabStop = function(col) { var i = self.tabStops.length; while(self.tabStops[--i] >= col); return self.tabStops[i]; }; this.expandTab = function(col, expandChar) { expandChar = expandChar || ' '; return new Array(self.getRemainingTabWidth(col)).join(expandChar); }; this.wordWrapSingleLine = function(s, tabHandling, width) { if(!_.isNumber(width)) { width = self.dimens.width; } return wordWrapText( s, { width : width, tabHandling : tabHandling || 'expand', tabWidth : self.tabWidth, tabChar : '\t', } ); }; this.setTextLines = function(lines, index, termWithEol) { if(0 === index && (0 === self.textLines.length || (self.textLines.length === 1 && '' === self.textLines[0].text) )) { // quick path: just set the things self.textLines = lines.slice(0, -1).map(l => { return { text : l }; }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); } else { // insert somewhere in textLines... if(index > self.textLines.length) { // fill with empty self.textLines.splice( self.textLines.length, 0, ...Array.from( { length : index - self.textLines.length } ).map( () => { return { text : '' }; } ) ); } const newLines = lines.slice(0, -1).map(l => { return { text : l }; }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); self.textLines.splice( index, 0, ...newLines ); } }; this.setAnsiWithOptions = function(ansi, options, cb) { function setLines(text) { text = strUtil.splitTextAtTerms(text); let index = 0; text.forEach(line => { self.setTextLines( [ line ], index, true); // true=termWithEol index += 1; }); self.cursorStartOfDocument(); if(cb) { return cb(null); } } if(options.prepped) { return setLines(ansi); } ansiPrep( ansi, { termWidth : this.client.term.termWidth, termHeight : this.client.term.termHeight, cols : this.dimens.width, rows : 'auto', startCol : this.position.col, forceLineTerm : options.forceLineTerm, }, (err, preppedAnsi) => { return setLines(err ? ansi : preppedAnsi); } ); }; this.insertRawText = function(text, index, col) { // // Perform the following on |text|: // * Normalize various line feed formats -> \n // * Remove some control characters (e.g. \b) // * Word wrap lines such that they fit in the visible workspace. // Each actual line will then take 1:n elements in textLines[]. // * Each tab will be appropriately expanded and take 1:n \t // characters. This allows us to know when we're in tab space // when doing cursor movement/etc. // // // Try to handle any possible newline that can be fed to us. // See http://stackoverflow.com/questions/5034781/js-regex-to-split-by-line // // :TODO: support index/col insertion point if(_.isNumber(index)) { if(_.isNumber(col)) { // // Modify text to have information from index // before and and after column // // :TODO: Need to clean this string (e.g. collapse tabs) text = self.textLines; // :TODO: Remove original line @ index } } else { index = self.textLines.length; } text = strUtil.splitTextAtTerms(text); let wrapped; text.forEach(line => { wrapped = self.wordWrapSingleLine( line, // line to wrap 'expand', // tabHandling self.dimens.width ).wrapped; self.setTextLines(wrapped, index, true); // true=termWithEol index += wrapped.length; }); }; this.getAbsolutePosition = function(row, col) { return { row : self.position.row + row, col : self.position.col + col, }; }; this.moveClientCursorToCursorPos = function() { var absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); self.client.term.rawWrite(ansi.goto(absPos.row, absPos.col)); }; this.keyPressCharacter = function(c) { var index = self.getTextLinesIndex(); // // :TODO: stuff that needs to happen // * Break up into smaller methods // * Even in overtype mode, word wrapping must apply if past bounds // * A lot of this can be used for backspacing also // * See how Sublime treats tabs in *non* overtype mode... just overwrite them? // // if(self.overtypeMode) { // :TODO: special handing for insert over eol mark? self.replaceCharacterInText(c, index, self.cursorPos.col); self.cursorPos.col++; self.client.term.write(c); } else { self.insertCharactersInText(c, index, self.cursorPos.col); } self.emitEditPosition(); }; this.keyPressUp = function() { if(self.cursorPos.row > 0) { self.cursorPos.row--; self.client.term.rawWrite(ansi.up()); if(!self.adjustCursorToNextTab('up')) { self.adjustCursorIfPastEndOfLine(false); } } else { self.scrollDocumentDown(); self.adjustCursorIfPastEndOfLine(true); } self.emitEditPosition(); }; this.keyPressDown = function() { var lastVisibleRow = Math.min( self.dimens.height, (self.textLines.length - self.topVisibleIndex)) - 1; if(self.cursorPos.row < lastVisibleRow) { self.cursorPos.row++; self.client.term.rawWrite(ansi.down()); if(!self.adjustCursorToNextTab('down')) { self.adjustCursorIfPastEndOfLine(false); } } else { self.scrollDocumentUp(); self.adjustCursorIfPastEndOfLine(true); } self.emitEditPosition(); }; this.keyPressLeft = function() { if(self.cursorPos.col > 0) { var prevCharIsTab = self.isTab(); self.cursorPos.col--; self.client.term.rawWrite(ansi.left()); if(prevCharIsTab) { self.adjustCursorToNextTab('left'); } } else { self.cursorEndOfPreviousLine(); } self.emitEditPosition(); }; this.keyPressRight = function() { var eolColumn = self.getTextEndOfLineColumn(); if(self.cursorPos.col < eolColumn) { var prevCharIsTab = self.isTab(); self.cursorPos.col++; self.client.term.rawWrite(ansi.right()); if(prevCharIsTab) { self.adjustCursorToNextTab('right'); } } else { self.cursorBeginOfNextLine(); } self.emitEditPosition(); }; this.keyPressHome = function() { var firstNonWhitespace = self.getVisibleText().search(/\S/); if(-1 !== firstNonWhitespace) { self.cursorPos.col = firstNonWhitespace; } else { self.cursorPos.col = 0; } self.moveClientCursorToCursorPos(); self.emitEditPosition(); }; this.keyPressEnd = function() { self.cursorPos.col = self.getTextEndOfLineColumn(); self.moveClientCursorToCursorPos(); self.emitEditPosition(); }; this.keyPressPageUp = function() { if(self.topVisibleIndex > 0) { self.topVisibleIndex = Math.max(0, self.topVisibleIndex - self.dimens.height); self.redraw(); self.adjustCursorIfPastEndOfLine(true); } else { self.cursorPos.row = 0; self.moveClientCursorToCursorPos(); // :TODO: ajust if eol, etc. } self.emitEditPosition(); }; this.keyPressPageDown = function() { var linesBelow = self.getRemainingLinesBelowRow(); if(linesBelow > 0) { self.topVisibleIndex += Math.min(linesBelow, self.dimens.height); self.redraw(); self.adjustCursorIfPastEndOfLine(true); } self.emitEditPosition(); }; this.keyPressLineFeed = function() { // // Break up text from cursor position, redraw, and update cursor // position to start of next line // var index = self.getTextLinesIndex(); var nextEolIndex = self.getNextEndOfLineIndex(index); var text = self.getContiguousText(index, nextEolIndex); var newLines = self.wordWrapSingleLine(text.slice(self.cursorPos.col), 'tabsIntact').wrapped; newLines.unshift( { text : text.slice(0, self.cursorPos.col), eol : true } ); for(var i = 1; i < newLines.length; ++i) { newLines[i] = { text : newLines[i] }; } newLines[newLines.length - 1].eol = true; Array.prototype.splice.apply( self.textLines, [ index, (nextEolIndex - index) + 1 ].concat(newLines)); // redraw from current row to end of visible area self.redrawRows(self.cursorPos.row, self.dimens.height); self.cursorBeginOfNextLine(); self.emitEditPosition(); }; this.keyPressInsert = function() { self.toggleTextEditMode(); }; this.keyPressTab = function() { var index = self.getTextLinesIndex(); self.insertCharactersInText(self.expandTab(self.cursorPos.col, '\t') + '\t', index, self.cursorPos.col); self.emitEditPosition(); }; this.keyPressBackspace = function() { if(self.cursorPos.col >= 1) { // // Don't want to delete character at cursor, but rather the character // to the left of the cursor! // self.cursorPos.col -= 1; var index = self.getTextLinesIndex(); var count; if(self.isTab()) { var col = self.cursorPos.col; var prevTabStop = self.getPrevTabStop(self.cursorPos.col); while(col >= prevTabStop) { if(!self.isTab(index, col)) { break; } --col; } count = (self.cursorPos.col - col); } else { count = 1; } self.removeCharactersFromText( index, self.cursorPos.col, 'backspace', count); } else { // // Delete character at end of line previous. // * This may be a eol marker // * Word wrapping will need re-applied // // :TODO: apply word wrapping such that text can be re-adjusted if it can now fit on prev self.keyPressLeft(); // same as hitting left - jump to previous line //self.keyPressBackspace(); } self.emitEditPosition(); }; this.keyPressDelete = function() { self.removeCharactersFromText( self.getTextLinesIndex(), self.cursorPos.col, 'right', 1); self.emitEditPosition(); }; this.keyPressDeleteLine = function() { if(self.textLines.length > 0) { self.removeCharactersFromText( self.getTextLinesIndex(), 0, 'delete line'); } self.emitEditPosition(); }; this.adjustCursorIfPastEndOfLine = function(forceUpdate) { var eolColumn = self.getTextEndOfLineColumn(); if(self.cursorPos.col > eolColumn) { self.cursorPos.col = eolColumn; forceUpdate = true; } if(forceUpdate) { self.moveClientCursorToCursorPos(); } }; this.adjustCursorToNextTab = function(direction) { if(self.isTab()) { var move; switch(direction) { // // Next tabstop to the right // case 'right' : move = self.getNextTabStop(self.cursorPos.col) - self.cursorPos.col; self.cursorPos.col += move; self.client.term.rawWrite(ansi.right(move)); break; // // Next tabstop to the left // case 'left' : move = self.cursorPos.col - self.getPrevTabStop(self.cursorPos.col); self.cursorPos.col -= move; self.client.term.rawWrite(ansi.left(move)); break; case 'up' : case 'down' : // // Jump to the tabstop nearest the cursor // var newCol = self.tabStops.reduce(function r(prev, curr) { return (Math.abs(curr - self.cursorPos.col) < Math.abs(prev - self.cursorPos.col) ? curr : prev); }); if(newCol > self.cursorPos.col) { move = newCol - self.cursorPos.col; self.cursorPos.col += move; self.client.term.rawWrite(ansi.right(move)); } else if(newCol < self.cursorPos.col) { move = self.cursorPos.col - newCol; self.cursorPos.col -= move; self.client.term.rawWrite(ansi.left(move)); } break; } return true; } return false; // did not fall on a tab }; this.cursorStartOfDocument = function() { self.topVisibleIndex = 0; self.cursorPos = { row : 0, col : 0 }; self.redraw(); self.moveClientCursorToCursorPos(); }; this.cursorEndOfDocument = function() { self.topVisibleIndex = Math.max(self.textLines.length - self.dimens.height, 0); self.cursorPos.row = (self.textLines.length - self.topVisibleIndex) - 1; self.cursorPos.col = self.getTextEndOfLineColumn(); self.redraw(); self.moveClientCursorToCursorPos(); }; this.cursorBeginOfNextLine = function() { // e.g. when scrolling right past eol var linesBelow = self.getRemainingLinesBelowRow(); if(linesBelow > 0) { var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; if(self.cursorPos.row < lastVisibleRow) { self.cursorPos.row++; } else { self.scrollDocumentUp(); } self.keyPressHome(); // same as pressing 'home' } }; this.cursorEndOfPreviousLine = function() { // e.g. when scrolling left past start of line var moveToEnd; if(self.cursorPos.row > 0) { self.cursorPos.row--; moveToEnd = true; } else if(self.topVisibleIndex > 0) { self.scrollDocumentDown(); moveToEnd = true; } if(moveToEnd) { self.keyPressEnd(); // same as pressing 'end' } }; /* this.cusorEndOfNextLine = function() { var linesBelow = self.getRemainingLinesBelowRow(); if(linesBelow > 0) { var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; if(self.cursorPos.row < lastVisibleRow) { self.cursorPos.row++; } else { self.scrollDocumentUp(); } self.keyPressEnd(); // same as pressing 'end' } }; */ this.scrollDocumentUp = function() { // // Note: We scroll *up* when the cursor goes *down* beyond // the visible area! // var linesBelow = self.getRemainingLinesBelowRow(); if(linesBelow > 0) { self.topVisibleIndex++; self.redraw(); } }; this.scrollDocumentDown = function() { // // Note: We scroll *down* when the cursor goes *up* beyond // the visible area! // if(self.topVisibleIndex > 0) { self.topVisibleIndex--; self.redraw(); } }; this.emitEditPosition = function() { self.emit('edit position', self.getEditPosition()); }; this.toggleTextEditMode = function() { self.overtypeMode = !self.overtypeMode; self.emit('text edit mode', self.getTextEditMode()); }; this.insertRawText(''); // init to blank/empty } require('util').inherits(MultiLineEditTextView, View); MultiLineEditTextView.prototype.setWidth = function(width) { MultiLineEditTextView.super_.prototype.setWidth.call(this, width); this.calculateTabStops(); }; MultiLineEditTextView.prototype.redraw = function() { MultiLineEditTextView.super_.prototype.redraw.call(this); this.redrawVisibleArea(); }; MultiLineEditTextView.prototype.setFocus = function(focused) { this.client.term.rawWrite(this.getSGRFor('text')); this.moveClientCursorToCursorPos(); MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); }; MultiLineEditTextView.prototype.setText = function(text, options = { scrollMode : 'default' } ) { this.textLines = [ ]; this.addText(text, options); /*this.insertRawText(text); if(this.isEditMode()) { this.cursorEndOfDocument(); } else if(this.isPreviewMode()) { this.cursorStartOfDocument(); }*/ }; MultiLineEditTextView.prototype.setAnsi = function(ansi, options = { prepped : false }, cb) { this.textLines = [ ]; return this.setAnsiWithOptions(ansi, options, cb); }; MultiLineEditTextView.prototype.addText = function(text, options = { scrollMode : 'default' }) { this.insertRawText(text); switch(options.scrollMode) { case 'default' : if(this.isEditMode() || this.autoScroll) { this.cursorEndOfDocument(); } else { this.cursorStartOfDocument(); } break; case 'top' : case 'start' : this.cursorStartOfDocument(); break; case 'end' : case 'bottom' : this.cursorEndOfDocument(); break; } }; MultiLineEditTextView.prototype.getData = function(options = { forceLineTerms : false } ) { return this.getOutputText(0, this.textLines.length, '\r\n', options); }; MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) { switch(propName) { case 'mode' : this.mode = value; if('preview' === value && !this.specialKeyMap.next) { this.specialKeyMap.next = [ 'tab' ]; } break; case 'autoScroll' : this.autoScroll = value; break; case 'tabSwitchesView' : this.tabSwitchesView = value; this.specialKeyMap.next = this.specialKeyMap.next || []; this.specialKeyMap.next.push('tab'); break; } MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); }; const HANDLED_SPECIAL_KEYS = [ 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down', 'line feed', 'insert', 'tab', 'backspace', 'del', 'delete line', ]; const PREVIEW_MODE_KEYS = [ 'up', 'down', 'page up', 'page down' ]; MultiLineEditTextView.prototype.onKeyPress = function(ch, key) { const self = this; let handled; if(key) { HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) { if(self.isKeyMapped(specialKey, key.name)) { if(self.isPreviewMode() && -1 === PREVIEW_MODE_KEYS.indexOf(specialKey)) { return; } if('tab' !== key.name || !self.tabSwitchesView) { self[_.camelCase('keyPress ' + specialKey)](); handled = true; } } }); } if(self.isEditMode() && ch && strUtil.isPrintable(ch)) { this.keyPressCharacter(ch); } if(!handled) { MultiLineEditTextView.super_.prototype.onKeyPress.call(this, ch, key); } }; MultiLineEditTextView.prototype.scrollUp = function() { this.scrollDocumentUp(); }; MultiLineEditTextView.prototype.scrollDown = function() { this.scrollDocumentDown(); }; MultiLineEditTextView.prototype.deleteLine = function(line) { this.textLines.splice(line, 1); }; MultiLineEditTextView.prototype.getLineCount = function() { return this.textLines.length; }; MultiLineEditTextView.prototype.getTextEditMode = function() { return this.overtypeMode ? 'overtype' : 'insert'; }; MultiLineEditTextView.prototype.getEditPosition = function() { var currentIndex = this.getTextLinesIndex() + 1; return { row : this.getTextLinesIndex(this.cursorPos.row), col : this.cursorPos.col, percent : Math.floor(((currentIndex / this.textLines.length) * 100)), below : this.getRemainingLinesBelowRow(), }; };