enigma-bbs/core/multi_line_edit_text_view.js

1214 lines
37 KiB
JavaScript

/* jslint node: true */
'use strict';
const View = require('./view.js').View;
const strUtil = require('./string_util.js');
const ansi = require('./ansi_term.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 should 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 word delete (CTRL+????)
// *
const SPECIAL_KEY_MAP_DEFAULT = {
'line feed' : [ 'return' ],
exit : [ 'esc' ],
backspace : [ 'backspace' ],
delete : [ 'delete' ],
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) {
const nextEolIndex = self.getNextEndOfLineIndex(index);
const wrapped = self.wordWrapSingleLine(self.getContiguousText(index, nextEolIndex), 'tabsIntact');
const newLines = wrapped.wrapped.map(l => { return { text : l }; } );
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('delete' === operation) {
self.textLines[index].text =
self.textLines[index].text.slice(0, col) +
self.textLines[index].text.slice(col + count);
self.updateTextWordWrap(index);
self.redrawRows(self.cursorPos.row, self.dimens.height);
self.moveClientCursorToCursorPos();
} 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) {
const prevTextLength = self.getTextLength(index);
let editingEol = self.cursorPos.col === prevTextLength;
self.textLines[index].text = [
self.textLines[index].text.slice(0, col),
c,
self.textLines[index].text.slice(col)
].join('');
self.cursorPos.col += c.length;
if(self.getTextLength(index) > self.dimens.width) {
//
// Update word wrapping and |cursorOffset| if the cursor
// was within the bounds of the wrapped text
//
let cursorOffset;
const lastCol = self.cursorPos.col - c.length;
const firstWrapRange = self.updateTextWordWrap(index);
if(lastCol >= firstWrapRange.start && lastCol <= firstWrapRange.end) {
cursorOffset = self.cursorPos.col - firstWrapRange.start;
editingEol = true; //override
} else {
cursorOffset = firstWrapRange.end;
}
// redraw from current row to end of visible area
self.redrawRows(self.cursorPos.row, self.dimens.height);
// If we're editing mid, we're done here. Else, we need to
// move the cursor to the new editing position after a wrap
if(editingEol) {
self.cursorBeginOfNextLine();
self.cursorPos.col += cursorOffset;
self.client.term.rawWrite(ansi.right(cursorOffset));
} else {
// adjust cursor after drawing new rows
const absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col);
self.client.term.rawWrite(ansi.goto(absPos.row, absPos.col));
}
} else {
//
// We must only redraw from col -> end of current visible line
//
const absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col);
const renderText = self.getRenderText(index).slice(self.cursorPos.col - c.length);
self.client.term.write(
`${ansi.hideCursor()}${self.getSGRFor('text')}${renderText}${ansi.goto(absPos.row, absPos.col)}${ansi.showCursor()}`,
false // convertLineFeeds
);
}
};
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(line, tabHandling = 'expand') {
return wordWrapText(
line,
{
width : self.dimens.width,
tabHandling : tabHandling,
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, 'expand').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);
const 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() {
const lineIndex = self.getTextLinesIndex();
if(0 === self.cursorPos.col && 0 === self.textLines[lineIndex].text.length && self.textLines.length > 0) {
//
// Start of line and nothing left. Just delete the line
//
self.removeCharactersFromText(
lineIndex,
0,
'delete line'
);
} else {
self.removeCharactersFromText(
lineIndex,
self.cursorPos.col,
'delete',
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', 'delete',
'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(),
};
};