enigma-bbs/core/multi_line_edit_text_view2.js

719 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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
// *
var SPECIAL_KEY_MAP_DEFAULT = {
lineFeed : [ 'return' ],
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' ],
};
exports.MultiLineEditTextView2 = MultiLineEditTextView2;
function MultiLineEditTextView2(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 :)
//
this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 4;
this.textLines = [];
this.topVisibleIndex = 0;
//
// 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();
};
// :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.redrawRows = function(startRow, endRow) {
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);
};
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[index].text;
};
this.getTextEndOfLineColumn = function(index) {
return Math.max(0, self.getText(index).length);
};
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'
//
var re = new RegExp(
'\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 = 0;
var results = { wrapped : [ '' ] };
var i = 0;
var word;
function addWord() {
word.match(new RegExp('.{0,' + self.dimens.width + '}', 'g')).forEach(function wrd(w) {
if(results.wrapped[i].length + w.length >= self.dimens.width) {
if(0 === i) {
results.firstWrapRange = { start : wordStart, end : wordStart + w.length };
}
results.wrapped[++i] = w;
} else {
results.wrapped[i] += w;
}
});
}
while((m = re.exec(s)) !== null) {
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(results.wrapped[i].length + word.length, '\t');
break;
}
addWord();
wordStart = re.lastIndex + m[0].length - 1;
}
//
// Remainder
//
console.log(wordStart + ' / ' + s.length)
word = s.substring(wordStart);
addWord();
return results;
};
// :TODO: Change this to (text, row, col) & make proper adjustments
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
//
// :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 = text
.replace(/\b/g, '')
.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
var wrapped;
for(var i = 0; i < text.length; ++i) {
wrapped = self.wordWrapSingleLine(text[i], self.dimens.width).wrapped;
for(var j = 0; j < wrapped.length - 1; ++j) {
self.textLines.splice(index++, 0, { text : wrapped[j] } );
}
self.textLines.splice(index++, 0, { text : wrapped[wrapped.length - 1], eol : true });
}
};
this.getAbsolutePosition = function(row, col) {
return {
row : self.position.row + row,
col : self.position.col + col,
};
};
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++;
var text = self.getText(index);
var cursorOffset;
var absPos;
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 + ' / firstWrapRange=' + JSON.stringify(wrapped.firstWrapRange))
if(lastCol >= wrapped.firstWrapRange.start && lastCol <= wrapped.firstWrapRange.end) {
cursorOffset = self.cursorPos.col - wrapped.firstWrapRange.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() +
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();
//self.client.term.write(ansi.right(cursorOffset))
}
}
};
this.keyPressUp = function() {
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);
}
};
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);
}
};
this.keyPressLeft = function() {
if(self.cursorPos.col > 0) {
self.cursorPos.col--;
self.client.term.write(ansi.left());
// :TODO: handle landing on a tab
} else {
self.cursorEndOfPreviousLine();
}
};
this.keyPressRight = function() {
var eolColumn = self.getTextEndOfLineColumn();
if(self.cursorPos.col < eolColumn) {
self.cursorPos.col++;
self.client.term.write(ansi.right());
self.adjustCursorToNextTab('right');
} else {
self.cursorBeginOfNextLine();
}
};
this.keyPressHome = function() {
var firstNonWhitespace = self.getVisibleText().search(/\S/);
if(-1 !== firstNonWhitespace) {
self.cursorPos.col = firstNonWhitespace;
} else {
self.cursorPos.col = 0;
}
console.log('"' + self.getVisibleText() + '"')
self.moveClientCusorToCursorPos();
};
this.keyPressEnd = function() {
self.cursorPos.col = self.getTextEndOfLineColumn();
self.moveClientCusorToCursorPos();
};
this.keyPressPageUp = function() {
};
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() {
self.topVisibleIndex = 0;
self.cursorPos = { row : 0, col : 0 };
self.redraw();
self.moveClientCusorToCursorPos();
};
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.moveClientCusorToCursorPos();
};
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();
};
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/nuskooler/Downloads/test_text.txt', { encoding : 'utf-8'});
//text = 'An excerpt from A Clockwork Orange:'
this.insertText(text);//, 0, 0);
this.cursorEndOfDocument();
// console.log(this.textLines)
};
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) {
MultiLineEditTextView2.super_.prototype.onKeyPress.call(this, ch, key);
}
};