enigma-bbs/core/multi_line_edit_text_view.js

519 lines
14 KiB
JavaScript

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