enigma-bbs/core/multi_line_edit_text_view2.js

721 lines
19 KiB
JavaScript
Raw Normal View History

/* 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 TextBuffer = require('./text_buffer.js').TextBuffer;
var assert = require('assert');
var _ = 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
//
//
// To-Do
//
// * Word wrap from pos to next { eol : true } when inserting text
// * Page up/down just divide by and set top index
// * Index pos % for emit scroll events
// *
2015-06-03 04:18:00 +00:00
var SPECIAL_KEY_MAP_DEFAULT = {
lineFeed : [ 'return' ],
2015-06-03 04:18:00 +00:00
exit : [ 'esc' ],
backspace : [ 'backspace' ],
del : [ 'del' ],
tabs : [ 'tab' ],
up : [ 'up arrow' ],
down : [ 'down arrow' ],
end : [ 'end' ],
home : [ 'home' ],
left : [ 'left arrow' ],
right : [ 'right arrow' ],
clearLine : [ 'ctrl + y' ],
pageUp : [ 'page up' ],
pageDown : [ 'page down' ],
insert : [ 'insert', 'ctrl + v' ],
};
2015-06-03 04:18:00 +00:00
exports.MultiLineEditTextView2 = MultiLineEditTextView2;
function MultiLineEditTextView2(options) {
if(!_.isBoolean(options.acceptsFocus)) {
options.acceptsFocus = true;
}
if(!_.isBoolean(this.acceptsInput)) {
options.acceptsInput = true;
}
2015-06-03 04:18:00 +00:00
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 :)
//
this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 4;
2015-06-02 05:00:54 +00:00
this.textLines = [];
this.topVisibleIndex = 0;
2015-06-03 04:18:00 +00:00
//
// cursorPos represents zero-based row, col positions
// within the editor itself
//
2015-06-02 22:36:55 +00:00
this.cursorPos = { col : 0, row : 0 };
2015-06-08 22:51:27 +00:00
this.getSGRFor = function(sgrFor) {
return {
text : self.getSGR(),
}[sgrFor] || self.getSGR();
};
// :TODO: Most of the calls to this could be avoided via incrementRow(), decrementRow() that keeps track or such
2015-06-03 04:18:00 +00:00
this.getTextLinesIndex = function(row) {
if(!_.isNumber(row)) {
row = self.cursorPos.row;
}
var index = self.topVisibleIndex + row;
2015-06-03 04:18:00 +00:00
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.redrawRows = function(startRow, endRow) {
2015-06-08 22:51:27 +00:00
self.client.term.write(self.getSGRFor('text') + ansi.hideCursor());
var startIndex = self.getTextLinesIndex(startRow);
var endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length);
var absPos = self.getAbsolutePosition(startRow, 0);
for(var i = startIndex; i < endIndex; ++i) {
self.client.term.write(ansi.goto(absPos.row++, absPos.col));
self.client.term.write(self.getRenderText(i));
}
self.client.term.write(ansi.showCursor());
};
this.redrawVisibleArea = function() {
assert(self.topVisibleIndex <= self.textLines.length);
self.redrawRows(0, self.dimens.height);
};
2015-06-02 05:00:54 +00:00
2015-06-02 22:36:55 +00:00
this.getVisibleText = function(index) {
if(!_.isNumber(index)) {
index = self.getTextLinesIndex();
}
2015-06-02 22:36:55 +00:00
return self.textLines[index].text.replace(/\t/g, ' ');
};
this.getText = function(index) {
if(!_.isNumber(index)) {
index = self.getTextLinesIndex();
}
return self.textLines[index].text;
};
this.getTextEndOfLineColumn = function(index) {
return Math.max(0, self.getText(index).length);
};
2015-06-02 22:36:55 +00:00
this.getRenderText = function(index) {
var text = self.getVisibleText(index);
var remain = self.dimens.width - text.length;
if(remain > 0) {
text += new Array(remain).join(' ');
}
return text;
};
this.getOutputText = function(startIndex, endIndex, includeEol) {
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."
}
//
// Convert lines to contiguous string -- all expanded
// tabs put back to single '\t' characters.
//
var text = '';
var re = new RegExp('\\t{' + (self.tabWidth - 1) + '}', 'g');
for(var i = 0; i < lines.length; ++i) {
text += lines[i].text.replace(re, '\t');
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.insertCharacterInText = function(c, index, col) {
self.textLines[index].text = [
self.textLines[index].text.slice(0, col),
c,
self.textLines[index].text.slice(col)
].join('');
};
this.getRemainingTabWidth = function(col) {
if(!_.isNumber(col)) {
col = self.cursorPos.col;
}
return self.tabWidth - (col % self.tabWidth);
};
this.expandTab = function(col, expandChar) {
expandChar = expandChar || ' ';
return new Array(self.getRemainingTabWidth(col)).join(expandChar);
};
this.wordWrapSingleLine = function(s, width) {
//
// Notes
// * Sublime Text 3 for example considers spaces after a word
// part of said word. For example, "word " would be wraped
// in it's entirity.
//
// * Tabs in Sublime Text 3 are also treated as a word, so, e.g.
// "\t" may resolve to " " and must fit within the space.
//
// * If a word is ultimately too long to fit, break it up until it does.
//
// RegExp below is JavaScript '\s' minus the '\t'
//
2015-06-02 05:00:54 +00:00
var re = new RegExp(
2015-06-02 22:36:55 +00:00
'\t|[ \f\n\r\v\u00a0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006' +
'\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000]+', 'g');
var m;
var wordStart;
var wrapped = [ '' ];
var i = 0;
var word;
var firstWordWrapWordRange;
function addWord() {
word.match(new RegExp('.{0,' + self.dimens.width + '}', 'g')).forEach(function wrd(w) {
if(wrapped[i].length + w.length >= self.dimens.width) {
if(0 === i) {
firstWordWrapWordRange = { start : wordStart, end : wordStart + w.length };
}
wrapped[++i] = w;
} else {
wrapped[i] += w;
}
});
}
do {
wordStart = re.lastIndex + (_.isObject(m) ? m[0].length - 1 : 0);
m = re.exec(s);
if(null !== m) {
2015-06-02 05:00:54 +00:00
word = s.substring(wordStart, re.lastIndex - 1);
switch(m[0].charAt(0)) {
case ' ' :
word += m[0];
break;
case '\t' :
//
// Expand tab given position
//
// Nice info here: http://c-for-dummies.com/blog/?p=424
//
word += self.expandTab(wrapped[i].length + word.length, '\t');
break;
}
addWord();
}
} while(0 !== re.lastIndex);
//
// Remainder
//
word = s.substring(wordStart);
addWord();
return { wrapped : wrapped, firstWordWrapWordRange : firstWordWrapWordRange };
};
// :TODO: Change this to (text, row, col) & make proper adjustments
2015-06-03 04:18:00 +00:00
this.insertText = 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
//
2015-06-03 04:18:00 +00:00
// :TODO: support index/col insertion point
2015-06-02 05:00:54 +00:00
2015-06-03 04:18:00 +00:00
if(_.isNumber(index)) {
2015-06-02 05:00:54 +00:00
if(_.isNumber(col)) {
//
2015-06-03 04:18:00 +00:00
// Modify text to have information from index
2015-06-02 05:00:54 +00:00
// before and and after column
//
// :TODO: Need to clean this string (e.g. collapse tabs)
text = self.textLines
2015-06-03 04:18:00 +00:00
// :TODO: Remove original line @ index
2015-06-02 05:00:54 +00:00
}
} else {
2015-06-03 04:18:00 +00:00
index = self.textLines.length;
2015-06-02 05:00:54 +00:00
}
text = text
.replace(/\b/g, '')
.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
var wrapped;
2015-06-02 05:00:54 +00:00
for(var i = 0; i < text.length; ++i) {
wrapped = self.wordWrapSingleLine(text[i], self.dimens.width).wrapped;
2015-06-02 05:00:54 +00:00
for(var j = 0; j < wrapped.length - 1; ++j) {
2015-06-03 04:18:00 +00:00
self.textLines.splice(index++, 0, { text : wrapped[j] } );
2015-06-02 05:00:54 +00:00
}
2015-06-03 04:18:00 +00:00
self.textLines.splice(index++, 0, { text : wrapped[wrapped.length - 1], eol : true });
}
};
2015-06-02 05:00:54 +00:00
2015-06-04 23:06:37 +00:00
this.getAbsolutePosition = function(row, col) {
return {
row : self.position.row + row,
col : self.position.col + col,
};
2015-06-04 23:06:37 +00:00
};
this.moveClientCusorToCursorPos = function() {
var absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col);
self.client.term.write(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 the cursor is part of a word that wraps (beg/end/mid), then the cursor
// should be restored at the same position in teh wrapped line
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.insertCharacterInText(c, index, self.cursorPos.col);
self.cursorPos.col++;
2015-06-08 06:03:09 +00:00
var text = self.getText(index);
var cursorOffset;
var absPos;
2015-06-08 06:03:09 +00:00
if(self.getText(index).length >= self.dimens.width) {
//
// Past available space -- word wrap from current point
// to the next EOL. Update textLines with the newly
// formatted array.
//
var nextEolIndex = self.getNextEndOfLineIndex(index);
var wrapped = self.wordWrapSingleLine(self.getOutputText(index, nextEolIndex));
var newLines = wrapped.wrapped;
//
// If our cursor was within the bounds of the last wrapped word
// we'll want to adjust the cursor to the same relative position
// on the next line.
//
var lastCol = self.cursorPos.col - 1;
console.log('lastCol=' + lastCol + ' / firstWordWrapWordRange=' + JSON.stringify(wrapped.firstWordWrapWordRange))
if(lastCol >= wrapped.firstWordWrapWordRange.start && lastCol <= wrapped.firstWordWrapWordRange.end) {
cursorOffset = self.cursorPos.col - wrapped.firstWordWrapWordRange.start;
console.log('cursorOffset=' + cursorOffset)
}
console.log('getOutputText="' + self.getOutputText(index, nextEolIndex) + '"')
for(var i = 0; i < newLines.length; ++i) {
newLines[i] = { text : newLines[i] };
}
newLines[newLines.length - 1].eol = true;
/*console.log('--------------Newlines')
console.log(newLines)
console.log('--------------Textlines')
console.log(self.textLines)
console.log('nextEolIndex='+ nextEolIndex + ' / index=' + index + '/ newLines.length=' + newLines.length)
*/
Array.prototype.splice.apply(
self.textLines,
[ index, (nextEolIndex - index) + 1 ].concat(newLines));
console.log('----textLines:')
console.log(self.textLines)
console.log('--------------')
absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col);
// 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.write(ansi.right(cursorOffset));
} else {
self.client.term.write(ansi.goto(absPos.row, absPos.col));
}
} else {
//console.log('redraw col+\n' + self.getRenderText(index).slice(self.cursorPos.col - 1) )
//
// 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() +
2015-06-08 22:51:27 +00:00
self.getSGRFor('text') +
self.getRenderText(index).slice(self.cursorPos.col - 1) +
ansi.goto(absPos.row, absPos.col) +
ansi.showCursor()
);
}
if(self.cursorPos.col >= self.dimens.width) {
console.log('next line')
self.cursorBeginOfNextLine();
2015-06-08 06:03:09 +00:00
//self.client.term.write(ansi.right(cursorOffset))
}
}
};
2015-06-04 23:06:37 +00:00
this.keyPressUp = function() {
2015-06-03 04:18:00 +00:00
if(self.cursorPos.row > 0) {
self.cursorPos.row--;
self.client.term.write(ansi.up());
// :TODO: self.makeTabAdjustment('up')
self.adjustCursorIfPastEndOfLine(false);
} else {
self.scrollDocumentDown();
self.adjustCursorIfPastEndOfLine(true);
2015-06-03 04:18:00 +00:00
}
2015-06-02 05:00:54 +00:00
};
2015-06-02 22:36:55 +00:00
2015-06-04 23:06:37 +00:00
this.keyPressDown = function() {
var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1;
if(self.cursorPos.row < lastVisibleRow) {
self.cursorPos.row++;
self.client.term.write(ansi.down());
// :TODO: make tab adjustment if needed
self.adjustCursorIfPastEndOfLine(false);
} else {
self.scrollDocumentUp();
self.adjustCursorIfPastEndOfLine(true);
}
2015-06-02 22:36:55 +00:00
};
2015-06-04 23:06:37 +00:00
this.keyPressLeft = function() {
2015-06-03 04:18:00 +00:00
if(self.cursorPos.col > 0) {
self.cursorPos.col--;
self.client.term.write(ansi.left());
// :TODO: handle landing on a tab
} else {
self.cursorEndOfPreviousLine();
2015-06-03 04:18:00 +00:00
}
2015-06-02 22:36:55 +00:00
};
2015-06-04 23:06:37 +00:00
this.keyPressRight = function() {
var eolColumn = self.getTextEndOfLineColumn();
if(self.cursorPos.col < eolColumn) {
2015-06-02 22:36:55 +00:00
self.cursorPos.col++;
self.client.term.write(ansi.right());
2015-06-03 04:18:00 +00:00
self.adjustCursorToNextTab('right');
2015-06-02 22:36:55 +00:00
} else {
self.cursorBeginOfNextLine();
2015-06-02 22:36:55 +00:00
}
};
2015-06-04 23:06:37 +00:00
this.keyPressHome = function() {
2015-06-03 04:18:00 +00:00
var firstNonWhitespace = self.getVisibleText().search(/\S/);
if(-1 !== firstNonWhitespace) {
self.cursorPos.col = firstNonWhitespace;
} else {
self.cursorPos.col = 0;
}
console.log('"' + self.getVisibleText() + '"')
2015-06-03 04:18:00 +00:00
self.moveClientCusorToCursorPos();
};
2015-06-04 23:06:37 +00:00
this.keyPressEnd = function() {
self.cursorPos.col = self.getTextEndOfLineColumn();
2015-06-03 04:18:00 +00:00
self.moveClientCusorToCursorPos();
};
2015-06-04 23:06:37 +00:00
this.keyPressPageUp = function() {
};
2015-06-04 23:06:37 +00:00
this.keyPressPageDown = function() {
};
this.keyPressLineFeed = function() {
};
this.keyPressInsert = function() {
// :TODO: emit event
self.overtypeMode = !self.overtypeMode;
};
this.adjustCursorIfPastEndOfLine = function(forceUpdate) {
var eolColumn = self.getTextEndOfLineColumn();
if(self.cursorPos.col > eolColumn) {
self.cursorPos.col = eolColumn;
forceUpdate = true;
}
if(forceUpdate) {
self.moveClientCusorToCursorPos();
}
};
this.adjustCursorToNearestTab = function() {
//
// When pressing up or down and landing on a tab, jump
// to the nearest tabstop -- right or left.
//
};
this.adjustCursorToNextTab = function(direction) {
if('\t' === self.getText()[self.cursorPos.col]) {
//
// When pressing right or left, jump to the next
// tabstop in that direction.
//
if('right' === direction) {
// :TODO: This is not working correctly...
// A few observations:
// 1) Right/left should probably allow to land on a tab
// and only jump once another arrow is hit -- this lets the user edit @ that position
var move = self.getRemainingTabWidth() - 1;
self.cursorPos.col += move;
self.client.term.write(ansi.right(move));
}
}
};
this.cursorStartOfDocument = function() {
2015-06-02 22:36:55 +00:00
self.topVisibleIndex = 0;
self.cursorPos = { row : 0, col : 0 };
self.redraw();
self.moveClientCusorToCursorPos();
};
this.cursorEndOfDocument = function() {
2015-06-02 22:36:55 +00:00
self.topVisibleIndex = Math.max(self.textLines.length - self.dimens.height, 0);
2015-06-03 04:18:00 +00:00
self.cursorPos.row = (self.textLines.length - self.topVisibleIndex) - 1;
self.cursorPos.col = self.getTextEndOfLineColumn();
2015-06-02 22:36:55 +00:00
self.redraw();
self.moveClientCusorToCursorPos();
};
2015-06-03 04:18:00 +00:00
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.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();
}
};
}
require('util').inherits(MultiLineEditTextView2, View);
MultiLineEditTextView2.prototype.redraw = function() {
MultiLineEditTextView2.super_.prototype.redraw.call(this);
this.redrawVisibleArea();
};
2015-06-08 22:51:27 +00:00
MultiLineEditTextView2.prototype.setFocus = function(focused) {
this.client.term.write(this.getSGRFor('text'));
MultiLineEditTextView2.super_.prototype.setFocus.call(this, focused);
};
MultiLineEditTextView2.prototype.setText = function(text) {
this.textLines = [ ];
//text = "Tab:\r\n\tA\tB\tC\tD\tE\tF\tG\r\n reeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeally long word!!!";
text = require('fs').readFileSync('/home/bashby/Downloads/test_text.txt', { encoding : 'utf-8'});
2015-06-02 05:00:54 +00:00
this.insertText(text);//, 0, 0);
this.cursorEndOfDocument();
// console.log(this.textLines)
2015-06-02 05:00:54 +00:00
};
var HANDLED_SPECIAL_KEYS = [
'up', 'down', 'left', 'right',
'home', 'end',
'pageUp', 'pageDown',
'lineFeed',
'insert',
];
MultiLineEditTextView2.prototype.onKeyPress = function(ch, key) {
var self = this;
var handled;
if(key) {
HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) {
if(self.isSpecialKeyMapped(specialKey, key.name)) {
self[_.camelCase('keyPress ' + specialKey)]();
handled = true;
}
});
}
if(ch && strUtil.isPrintable(ch)) {
this.keyPressCharacter(ch);
}
if(!handled) {
2015-06-05 22:37:17 +00:00
MultiLineEditTextView2.super_.prototype.onKeyPress.call(this, ch, key);
}
};