/* CodeQ: an online programming tutor.
Copyright (C) 2015 UL FRI
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . */
/**
* Takes a div element and runs a console simulator within.
*/
(function () {
var noop = function () { return false; }, // ignore keypress
defaultKeyMappings = { // keyed by modifiers, each either 1 or 0: shift || ctrl || alt
'000': {
'ArrowLeft': function () { this.moveLeft(); return false; },
'ArrowRight': function () { this.moveRight(); return false; },
'ArrowUp': function () { this.showPreviousHistory(); return false; },
'ArrowDown': function () { this.showNextHistory(); return false; },
'Backspace': function () { this.deleteCharacterLeft(); return false; },
'Delete': function () { this.deleteCharacterRight(); return false; },
'End': function () { this.moveToEndOfLine(); return false; },
'Home': function () { this.moveToStartOfLine(); return false; },
'Enter': function () { return true; }, // if it returns false in line-buffered mode, then a line will never be formed -> no input ever
},
'100': {
'Backspace': function () { this.deleteCharacterLeft(); return false; },
}
};
// Conversion table from legacy key codes for assorted keys
// For details see:
// * https://w3c.github.io/uievents/
// * http://www.w3.org/TR/2015/WD-DOM-Level-3-Events-code-20150428/
// * http://www.w3.org/TR/2015/WD-DOM-Level-3-Events-key-20150427/
// and their references.
var legacyKeyConversion = {
0x03: {'code': 'Cancel'},
0x06: {'code': 'Help'},
0x08: {'code': 'Backspace'},
0x09: {'code': 'Tab'},
0x0c: {'code': 'Clear'},
0x0d: {'code': 'Enter'},
0x13: {'code': 'Pause'},
0x1b: {'code': 'Escape'},
0x20: {'code': 'Space', 'key': ' '},
0x21: {'code': 'PageUp'},
0x22: {'code': 'PageDown'},
0x23: {'code': 'End'},
0x24: {'code': 'Home'},
0x25: {'code': 'ArrowLeft'},
0x26: {'code': 'ArrowUp'},
0x27: {'code': 'ArrowRight'},
0x28: {'code': 'ArrowDown'},
0x2c: {'code': 'PrintScreen'},
0x2d: {'code': 'Insert'},
0x2e: {'code': 'Delete'},
0x6a: {'code': 'NumpadMultiply', 'key': '*'},
0x6b: {'code': 'NumpadAdd', 'key': '+'},
0x6c: {'code': 'NumpadComma', 'key': ','},
0x6d: {'code': 'NumpadSubtract', 'key': '-'},
0x6e: {'code': 'NumpadDecimal', 'key': '.'},
0x6f: {'code': 'NumpadDivide', 'key': '/'},
0x90: {'code': 'NumLock'},
0x91: {'code': 'ScrollLock'},
0xba: {'code': 'Semicolon', 'key': ';'},
0xbb: {'code': 'Equal', 'key': '='},
0xbc: {'code': 'Comma', 'key': ','},
0xbd: {'code': 'Minus', 'key': '-'},
0xbe: {'code': 'Period', 'key': '.'},
0xbf: {'code': 'Slash', 'key': '/'},
0xc0: {'code': 'Backquote', 'key': '`'},
0xdb: {'code': 'BracketLeft', 'key': '['},
0xdc: {'code': 'Backslash', 'key': '\\'},
0xdd: {'code': 'BracketRight', 'key': ']'},
0xde: {'code': 'Quote', 'key': "'"}
};
// generate the rest of the mappings
(function (table) {
var userAgent = navigator.userAgent,
i;
// digits
for (i = 0; i < 10; i++) table[0x30 + i] = {'code': 'Digit' + i, 'key': '' + i};
// characters: 'key' is the lower-cased character
for (i = 0; i < 26; i++) table[0x41 + i] = {'code': 'Key' + String.fromCharCode(0x41 + i), 'key': String.fromCharCode(0x61 + i)};
// numpad
for (i = 0; i < 10; i++) table[0x60 + i] = {'code': 'Numpad' + i, 'key': '' + i};
// function keys F1 to F24
for (i = 1; i < 25; i++) table[0x6f + i] = {'code': 'F' + i};
// override tables
if (userAgent.indexOf('Gecko/') >= 0) {
// Firefox
table[0x3b] = {'code': 'Semicolon', 'key': ';'};
table[0x3d] = {'code': 'Equal', 'key': '='};
table[0x6b] = {'code': 'Equal', 'key': '='};
table[0x6d] = {'code': 'Minus', 'key': '-'};
table[0xbb] = {'code': 'NumpadAdd', 'key': '+'};
table[0xbd] = {'code': 'NumpadSubtract', 'key': '-'};
if (navigator.platform.indexOf('Mac') >= 0) {
table[0x0c] = {'code': 'NumLock'};
table[0xad] = {'code': 'Minus', 'key': '-'};
}
else if (navigator.platform.indexOf('Win') >= 0) {
table[0xad] = {'code': 'Minus', 'key': '-'};
}
}
if (navigator.vendor && (navigator.vendor.indexOf('Apple') >= 0)) {
// Safari
table[0x03] = {'code': 'Enter'};
table[0x19] = {'code': 'Tab'};
}
if ((userAgent.indexOf('iPad') >= 0) || (userAgent.indexOf('iPod') >= 0) || (userAgent.indexOf('iPhone') >= 0)) {
// ios
table[0x0a] = {'code': 'Enter'};
}
})(legacyKeyConversion);
// some (older) browsers specify incorrect key codes, this is a fix-up mapping
var codeFixTable = {
'Esc': 'Escape',
'Left': 'ArrowLeft',
'Up': 'ArrowUp',
'Right': 'ArrowRight',
'Down': 'ArrowDown',
'Del': 'Delete'
};
/**
* Takes the given DOM Keyboard Event and returns a normalized representation.
* @param event
*/
var processKeyboardEvent = function (event) {
var keyCode, converted, result, c;
if (('code' in event) && (('key' in event) || ('keyIdentifier') in event)) {
// we have DOM3 KeyboardEvent, no conversion needed
result = {'code': codeFixTable[event.code] || event.code, 'key': event.key || event.keyIdentifier};
}
else {
keyCode = event.keyCode || event.which;
converted = legacyKeyConversion[keyCode];
if (converted) {
result = {'code': converted.code};
if (converted.key) result.key = converted.key;
else result.key = converted.code; // as per DOM3 spec: unprintables have the key the same as the code TODO: not true for all of them, e.g. AltLeft -> Alt
}
else {
// unknown key, try to set at least some info
result = {'code': ''};
c = String.fromCharCode(keyCode);
if (c !== '') result.key = c;
else result.key = 'Unidentified';
}
}
// char is set only in keypress events, never in keydown and keyup
// (keypress is not a part of any standard)
if ('char' in event) {
result.char = event.char;
}
else if ('charCode' in event) {
result.char = String.fromCharCode(event.charCode);
}
else if (event.type == 'keypress') {
// TODO: generate result.char somehow? Probably not needed, we should use the input event instead of the keypress event anyway.
}
return result;
};
var instanceCounter = 1; // for generating unique console instance numbers
codeq.makeConsole = function (jqElt, options) {
var lines = [],// console content, line descriptors of the form {'content': 'string', 'classNames': [], 'jqLine': jQuery object, 'row': number}
maxLines = (options && options.maxLines) || 10000, // how many lines to display at most
currentRow = 0, // cursor position
currentCol = 0,
jqContent = $('
'), // the rendering of the console
jqInput = $(''), // for receiving keypresses, clipboard content
inputDisabled = false, // whether to ignore input
lineBuffered = true, // whether we are line-buffered or not buffered
autoHistory = (options && options.autoHistory) || false, // whether to auto-decide what goes into the history buffer
history = [], // the list of entered lines, it is only appended
currentHistory = [], // the list of entered lines, some possibly modified (it is copied from history on each new line editing)
currentHistoryIndex = -1,
instanceNumber = instanceCounter++, // unique instace number, to be used with stuff tied to this instance, e.g.: jqElt events, life-cycle debug output
renderLine = function (lineDescriptor) {
var jqLine = lineDescriptor.jqLine,
content = lineDescriptor.content,
classes = lineDescriptor.classNames || [],
cursor = currentRow == lineDescriptor.row ? currentCol : null,
startCol = 0,
i = 0,
spans = [], // what to render, items are of the form {'content': string, 'cssClass': string}
entry, jq, e, beforeCursor, afterCursor, name,
addOrMergeSpan = function (content, className) {
if (spans.length > 0 && className === spans[spans.length-1].cssClass) {
// join content to previous span where the class is the same
spans[spans.length-1].content += content;
}
else {
// add a new span for a new class
spans.push({'content': content, 'cssClass': className});
}
};
// get or create line
if (!jqLine) {
jqLine = $('');
if (lineDescriptor.row == 0) {
jqContent.prepend(jqLine);
}
else if ((lineDescriptor.row < lines.length) && lines[lineDescriptor.row - 1].jqLine) {
lines[lineDescriptor.row - 1].jqLine.after(jqLine);
}
else {
// bail-out line, we should never end up here
jqContent.append(jqLine);
}
lineDescriptor.jqLine = jqLine;
}
else {
jqLine.empty();
}
// create spans
while (startCol < content.length) {
entry = i < classes.length ? classes[i]
: makeClassDescriptor('', startCol, content.length - startCol);
e = entry.start + entry.length; // end column of this css class
if (cursor !== null && cursor >= entry.start && cursor < e) {
// cursor is in this span
beforeCursor = content.substr(startCol, cursor - startCol);
if (beforeCursor) {
addOrMergeSpan(beforeCursor, entry.name);
}
spans.push({'content': content.charAt(cursor), 'cssClass': 'cq-con-cursor ' + entry.name});
afterCursor = content.substr(cursor + 1, e - cursor - 1);
if (afterCursor) {
spans.push({'content': afterCursor, 'cssClass': entry.name});
}
}
else {
// cursor is not in this span
addOrMergeSpan(content.substr(startCol, entry.length), entry.name);
}
startCol += entry.length;
i++;
}
// add a fake space if cursor is at end of line
if (cursor !== null && cursor >= content.length) {
name = entry ? entry.name : '';
spans.push({'content': ' ', 'cssClass': 'cq-con-cursor ' + name});
}
// render spans
for (i = 0; i < spans.length; i++) {
jq = $('');
jqLine.append(jq);
jq.text(spans[i].content);
}
// (re-)render cursor if it is in the current line
if (cursor !== null) {
renderCursor();
}
},
render = function () {
var i, lineDescriptor, n = lines.length;
jqContent.empty();
for (i = 0; i < n; i++) {
lineDescriptor = lines[i];
lineDescriptor.row = i;
if (lineDescriptor.jqLine) {
lineDescriptor.jqLine.remove();
lineDescriptor.jqLine = null;
}
renderLine(lineDescriptor);
}
},
formNewLines = function (newText) {
var newLines, newTextLines;
if (!newText) newLines = [];
else if (newText instanceof Array) {
newLines = [];
for (i = 0; i < newText.length; i++) {
newTextLines = ('' + newText[i]).split('\n');
for (j = 0; j < newTextLines.length; j++) newLines.push(newTextLines[j]);
}
}
else {
newLines = ('' + newText).split('\n');
}
return newLines;
},
makeClassDescriptor = function (name, start, length) {
return {'name': name, 'start': start, 'length': length};
},
indexOfClassAtOrAfterColumn = function (classes, col) {
var N = classes.length,
i;
for (i = 0; i < N; i++) {
if (classes[i].start >= col) return i;
}
return N;
},
insertClass = function (classes, newClassName, startingCol, length) {
var newEntry = makeClassDescriptor(newClassName, startingCol, length),
endCol = startingCol + length,
i, N, prevClass, pos, auxEntry, delta;
// seek out the position where to insert the new class
i = indexOfClassAtOrAfterColumn(classes, startingCol);
prevClass = i > 0 ? classes[i-1] : null;
// insert the new class
classes.splice(i, 0, newEntry);
// if needed, adjust the length of the previous class
if (prevClass) {
pos = prevClass.start + prevClass.length;
if (pos > startingCol) {
// split the previous class in two, the new class gets inserted in between
delta = pos - startingCol;
auxEntry = makeClassDescriptor(prevClass.name, endCol, delta);
classes.splice(i+1, 0, auxEntry);
prevClass.length = prevClass.length - delta;
}
}
// readjust positions of the classes after the new one
N = classes.length;
for (i++; i < N; i++) {
auxEntry = classes[i];
auxEntry.start = endCol;
endCol += auxEntry.length;
}
},
removeClasses = function (classes, startCol, length) {
var i, entry, endCol, e, prefixDelta, suffixDelta, N;
if (!length) return;
endCol = startCol + length;
for (i = classes.length - 1; i >= 0; i--) {
entry = classes[i];
e = entry.start + entry.length;
if ((e > startCol) && (entry.start < endCol)) {
prefixDelta = entry.start < startCol ? startCol - entry.start : 0;
suffixDelta = e > endCol ? e - endCol : 0;
if ((prefixDelta == 0) && (suffixDelta == 0)) classes.splice(i, 1);
else entry.length = prefixDelta + suffixDelta;
}
}
// readjust positions
N = classes.length;
endCol = 0;
for (i = 0; i < N; i++) {
entry = classes[i];
entry.start = endCol;
endCol += entry.length;
}
},
removeClassesBefore = function (classes, col) {
var i = indexOfClassAtOrAfterColumn(classes, col);
if (i == 0) return; // nothing to remove
classes.splice(0, i);
},
replaceClassesBeforeColumn = function (classes, col, newClassName, newLength) {
var i = indexOfClassAtOrAfterColumn(classes, col),
newEntry = makeClassDescriptor(newClassName, 0, newLength);
classes.splice(0, i, newEntry);
},
classesBetween = function (classes, startCol, endCol) {
var N = classes.length,
result = [],
i, e, entry, prefixDelta, suffixDelta;
for (i = 0; i < N; i++) {
entry = classes[i];
e = entry.start + entry.length; // end column of this css class
if ((e > startCol) && (entry.start < endCol)) {
prefixDelta = entry.start < startCol ? startCol - entry.start : 0;
suffixDelta = e > endCol ? e - endCol : 0;
result.push(makeClassDescriptor(entry.name, entry.start + prefixDelta, entry.length - prefixDelta - suffixDelta));
}
}
return result;
},
mergeClasses = function () {
var result = [],
i, arg, j, entry, endCol;
for (i = 0; i < arguments.length; i++) {
arg = arguments[i];
if (arg instanceof Array) {
for (j = 0; j < arg.length; j++) result.push(arg[j]);
}
else result.push(arg);
}
// adjust positions of css classes
endCol = 0;
for (i = 0; i < result.length; i++) {
entry = result[i];
entry.start = endCol;
endCol += entry.length;
}
return result;
},
scrollCursorIntoView = function () {
var dh = jqContent.height() - jqElt.height();
if (dh > 0)
jqElt.scrollTop(dh);
},
stashCurrentHistory = function () {
var leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0,
lineDescriptor = lines[currentRow],
line = lineDescriptor.content.substring(leftmost);
if (currentHistoryIndex < 0) {
currentHistoryIndex = currentHistory.length;
currentHistory.push(line);
}
else {
currentHistory[currentHistoryIndex] = line;
}
return lineDescriptor;
},
// the handler object that is returned
handler = {
'setLineBuffered': function () { lineBuffered = true; },
'setNotBuffered': function () { lineBuffered = false; },
'onInput': false, // the caller should assign a function here, that takes input as parameter, and optionally returns a promise which is then waited to be resolved before further input is accepted
'onKeypress': false, // same, but it takes a keycode, and returns a keycode
'inputEnable': function () { inputDisabled = false; },
'inputDisable': function () { inputDisabled = true; },
'keyMaps': {}, // will be filled in later
'leftmostCol': 0, // the column from where editing is possible; it is not possible to delete or change a character to the left of this column
'reflow': function () {
var startRow, endRow, i, lineDescriptor;
if (arguments.length == 0) {
// no arguments, reflow all lines
startRow = 0;
endRow = lines.length - 1;
}
else if (arguments.length == 1) {
// only the starting row given, reflow all lines from there on
startRow = arguments[0];
endRow = lines.length - 1;
}
else {
// both arguments are given
startRow = arguments[0];
endRow = arguments[1];
}
for (i = startRow; i <= endRow; i++) {
lineDescriptor = lines[i];
lineDescriptor.row = i;
renderLine(lineDescriptor);
}
},
'removeRow': function (row, deferReflow) {
var lineDescriptor;
if (row >= lines.length) return; // already absent
lineDescriptor = lines[row];
lines.splice(row, 1);
if (lineDescriptor.jqLine) {
lineDescriptor.jqLine.remove();
lineDescriptor.jqLine = null;
}
if (!deferReflow) this.reflow(row);
return lineDescriptor;
},
'insertRow': function (row, content, className, deferReflow) {
// inserts a new row at the position specified
var lineDescriptor, i;
if (!className) className = 'output';
for (i = lines.length; i < row; i++) {
// insert any empty rows
lineDescriptor = {content: '', classNames: [], row: i};
lines.push(lineDescriptor);
if (!deferReflow) renderLine(lineDescriptor);
}
lineDescriptor = {content: content, classNames: content.length > 0 ? [makeClassDescriptor(className, 0, content.length)] : [], row: row};
if (row >= lines.length) lines.push(lineDescriptor);
else lines.splice(row, 0, lineDescriptor);
if (!deferReflow) this.reflow(row);
return lineDescriptor;
},
'splice': function (startRow, startCol, endRow, endCol, newText, className) {
var i, j, n, startLine, endLine, newLines, part1, part2, s, auxLine;
if (!className) className = 'output';
// pre-process newText, convert it to newLines: an array of strings without newlines
newLines = formNewLines(newText);
if (newLines.length == 0) newLines.push(''); // there must at least be an empty "glue"
if (lines.length > 0) {
// there is something to remove
// verify sanity
if (startRow < 0) startRow = 0;
else if (startRow >= lines.length) startRow = lines.length - 1;
if (endRow < 0) endRow = 0;
else if (endRow >= lines.length) endRow = lines.length - 1;
if (endRow < startRow) {
// swap the meaning of the start and end coordinates
i = startRow;
startRow = endRow;
endRow = i;
i = startCol;
startCol = endCol;
endCol = i;
}
if (startRow == endRow) {
if (endCol < startCol) {
i = startCol;
startCol = endCol;
endCol = i;
}
}
startLine = lines[startRow];
endLine = lines[endRow];
if (startCol < 0) startCol = 0;
else if (startCol > startLine.length) startCol = startLine.length;
if (endCol < 0) endCol = 0;
else if (endCol > endLine.length) endCol = endLine.length;
// execute splice
part1 = startLine.content.substring(0, startCol); // the remaining content on the first and last lines
part2 = endLine.content.substring(endCol);
// modify the first and last lines, and make the list of the old lines to remove
j = startRow + 1; // where to start removing lines
n = endRow - startRow; // how many lines to remove
if (newLines.length > 1) {
// the first and the last new lines are separate existing lines, modify them
startLine.content = part1 + newLines[0];
startLine.classNames = mergeClasses(classesBetween(startLine.classNames, 0, part1.length), makeClassDescriptor(className, part1.length, newLines[0].length));
if (startRow == endRow) {
// we need to create one additional line to hold the compositum of the last line and part2
auxLine = this.insertRow(j, newLines[newLines.length - 1], className, true);
insertClass(auxLine.classNames, auxLine.content.length, part2.length);
auxLine.content = auxLine.content + part2;
j++;
}
else {
s = newLines[newLines.length - 1];
endLine.content = s + part2;
replaceClassesBeforeColumn(endLine.classNames, endCol, className, s.length);
n--; // retain the last line
}
}
else {
// the first and the last new lines are the same existing line
startLine.content = part1 + newLines[0] + part2;
startLine.classNames = mergeClasses(classesBetween(startLine.classNames, 0, part1.length), makeClassDescriptor(className, part1.length, newLines[0].length), classesBetween(endLine.classNames, endCol, endCol + part2.length));
}
// remove the rest of the old lines
while (n > 0) {
this.removeRow(j, true);
n--;
}
// insert new lines in between
j = newLines.length - 1; // the first and the last lines have already been set up
for (i = 1; i < j; i++) {
this.insertRow(startRow + i, newLines[i], className, true);
}
// optimization step: if only one line changed, then only reflow the line
if ((n == 0) && (newLines.length == 1)) {
renderLine(startLine);
}
else {
this.reflow(startRow);
}
}
else {
// no content exists, add all lines
for (i = 0; i < newLines.length; i++) {
this.insertRow(i, newLines[i], className, true);
}
this.reflow();
}
},
'insert': function (text, row, col, className) {
this.splice(row, col, row, col, text, className || 'output');
},
'append': function (text, className, dontMoveCursor) {
// we add to the end, so optimize: do not call splice(), it is too heavy for this purpose
var newLines = formNewLines(text),
originalCurrentRow = currentRow,
startRow = lines.length - 1,
i, lineDescriptor;
if (newLines.length == 0) return; // nothing to do
if (typeof className !== 'string') className = 'output';
lineDescriptor = lines[startRow];
insertClass(lineDescriptor.classNames, className, lineDescriptor.content.length, newLines[0].length);
lineDescriptor.content = lineDescriptor.content + newLines[0];
for (i = 1; i < newLines.length; i++) {
lineDescriptor = this.insertRow(startRow + i, newLines[i], className, true);
}
if (!dontMoveCursor) {
currentRow = lines.length - 1;
currentCol = lines[currentRow].content.length;
}
this.reflow(startRow < originalCurrentRow ? startRow : originalCurrentRow);
},
'insertAtCursor': function (text, className, dontMoveCursor) {
var startRow = currentRow,
currentLine = lines[currentRow],
currentContent = currentLine.content,
part1 = currentContent.substring(0, currentCol),
part2 = currentContent.substring(currentCol),
newLines = formNewLines(text),
i, n, auxLine;
if (typeof className !== 'string') className = 'output';
if (newLines.length == 0) return; // nothing to do
else if (newLines.length == 1) {
insertClass(currentLine.classNames, className, part1.length, newLines[0].length);
currentLine.content = part1 + newLines[0] + part2;
if (!dontMoveCursor) currentCol += newLines[0].length;
renderLine(currentLine);
}
else {
currentLine.classNames = mergeClasses(classesBetween(currentLine.classNames, 0, part1.length), makeClassDescriptor(className, part1.length, newLines[0].length));
currentLine.content = part1 + newLines[0];
n = newLines.length - 1;
for (i = 1; i < n; i++) {
this.insertRow(startRow + i, newLines[i], className, true);
}
auxLine = this.insertRow(startRow + n, newLines[n], className, true);
insertClass(auxLine.classNames, auxLine.content.length, part2.length);
auxLine.content = auxLine.content + part2;
if (!dontMoveCursor) {
currentRow = startRow + n;
currentCol = newLines[n].length;
}
this.reflow(startRow);
}
},
'moveLeft': function () {
var leftmost = typeof this.leftmostCol === 'number' && this.leftmostCol || 0;
if (currentCol > leftmost) {
currentCol--;
renderLine(lines[currentRow]);
}
scrollCursorIntoView();
},
'moveRight': function () {
var lineDescriptor = lines[currentRow];
if (currentCol < lineDescriptor.content.length) {
currentCol++;
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'deleteCharacterLeft': function () {
var lineDescriptor = lines[currentRow],
content = lineDescriptor.content,
leftmost = typeof this.leftmostCol === 'number' && this.leftmostCol || 0;
if ((currentCol > leftmost) && (currentCol <= content.length)) {
currentCol--;
removeClasses(lineDescriptor.classNames, currentCol, 1);
lineDescriptor.content = content.substring(0, currentCol) + content.substring(currentCol + 1);
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'deleteCharacterRight': function () {
var lineDescriptor = lines[currentRow],
content = lineDescriptor.content;
if ((currentCol >= 0) && (currentCol < content.length)) {
removeClasses(lineDescriptor.classNames, currentCol, 1);
lineDescriptor.content = content.substring(0, currentCol) + content.substring(currentCol + 1);
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'moveToEndOfLine': function () {
var lineDescriptor = lines[currentRow],
n = lineDescriptor.content.length;
if (currentCol != n) {
currentCol = n;
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'moveToStartOfLine': function () {
var leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0,
lineDescriptor = lines[currentRow];
if (currentCol != leftmost) {
currentCol = leftmost;
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'destroy': function () {
var n = lines.length,
i, lineDescriptor;
if (blinkTimer !== null) {
clearInterval(blinkTimer);
blinkTimer = null;
}
// detach all jQuery objects from internal structures
for (i = 0; i < n; i++) {
lineDescriptor = lines[i];
if (lineDescriptor.jqLine) {
lineDescriptor.jqLine.remove();
lineDescriptor.jqLine = null;
}
}
lines.length = 0;
// clear and detach the rest
render = null;
renderLine = null;
jqContent.remove();
jqContent = null;
jqInput.remove();
jqInput = null;
for (i in handler) {
if (handler.hasOwnProperty(i)) {
delete handler[i];
}
}
jqElt.off('click.console' + instanceNumber);
jqElt.off('blur.console' + instanceNumber);
jqElt.empty();
jqElt = null;
options = null;
},
'addHistory': function (line) {
history.push(line);
},
'currentInput': function () {
var leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0;
return lines[currentRow].content.substring(leftmost);
},
'resetCurrentHistory': function () {
var i;
currentHistory.length = 0;
for (i = 0; i < history.length; i++) currentHistory.push(history[i]);
currentHistoryIndex = -1;
},
'showPreviousHistory': function () {
var leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0,
lineDescriptor = stashCurrentHistory();
if (currentHistoryIndex > 0) {
currentHistoryIndex--;
lineDescriptor.content = lineDescriptor.content.substring(0, leftmost) + currentHistory[currentHistoryIndex];
currentCol = lineDescriptor.content.length;
lineDescriptor.classNames = mergeClasses(classesBetween(lineDescriptor.classNames, 0, leftmost), makeClassDescriptor('input', leftmost, currentCol - leftmost));
renderLine(lineDescriptor);
}
scrollCursorIntoView();
},
'showNextHistory': function () {
var leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0,
lineDescriptor = stashCurrentHistory();
if (currentHistoryIndex < (currentHistory.length - 1)) {
currentHistoryIndex++;
lineDescriptor.content = lineDescriptor.content.substring(0, leftmost) + currentHistory[currentHistoryIndex];
currentCol = lineDescriptor.content.length;
lineDescriptor.classNames = mergeClasses(classesBetween(lineDescriptor.classNames, 0, leftmost), makeClassDescriptor('input', leftmost, currentCol - leftmost));
renderLine(lineDescriptor);
}
scrollCursorIntoView();
}
},
internallyDisabled = false,
handleInput = function (chars) {
var isPastedFromClipboard = clipboardPasteInProgress || !aKeyIsDown,
cookedChars = chars, // the default
cookedPromise, cookedLines, lastCookedLineIndex, i;
if (inputDisabled || internallyDisabled) {
// flatly ignore
clipboardPasteInProgress = false;
return;
}
internallyDisabled = true;
cookedPromise = Q();
// first cook the input
if (typeof handler.onKeypress === 'function') {
// only invoke the handler if the newLine is not an empty string, otherwise cookedChars = ''
try {
cookedChars = cookedChars && handler.onKeypress(cookedChars, isPastedFromClipboard);
}
catch (e) {
codeq.log.error('Error during invocation of terminal onKeypress: ' + e, e);
}
}
if (typeof cookedChars !== 'string') cookedLines = [''];
else cookedLines = cookedChars.split('\n');
lastCookedLineIndex = cookedLines.length - 1;
// now serve the cooking
for (i = 0; i <= lastCookedLineIndex; i++) {
cookedPromise = cookedPromise
.then(scrollCursorIntoView) // scroll to bottom on input
.then((function (cookedLine, cookedLineIndex) {return function () {
var thisRow = currentRow,
leftmost = typeof handler.leftmostCol === 'number' && handler.leftmostCol || 0,
line;
if (cookedLine) handler.insertAtCursor(cookedLine, 'input'); // append what we have to the display
if (cookedLineIndex < lastCookedLineIndex) {
// press on "enter" must be simulated -> jump to the start of the next line, which is first added
currentRow++;
currentCol = 0;
handler.insertRow(currentRow, '', 'input', true);
handler.reflow(currentRow - 1); // the previous line must be rendered without a cursor, and the new line must be rendered completely
}
if (typeof handler.onInput === 'function') { // now tell the owner what we've done
if (lineBuffered) {
if (thisRow < currentRow) {
// in line-buffered mode emit each line separately, except for the last (current) line
try {
line = lines[thisRow].content.substring(leftmost);
if (autoHistory && line) handler.addHistory(line); // auto-history works only in line-buffered mode
handler.resetCurrentHistory();
return handler.onInput(line);
}
catch (e) {
codeq.log.error('Error while invoking terminal onInput: ' + e, e);
}
}
}
else if (cookedLine) {
try {
return handler.onInput(cookedLine);
}
catch (e) {
codeq.log.error('Error while invoking terminal onInput: ' + e, e);
}
}
}
};})(cookedLines[i], i))
.fail(function (e) {
codeq.log.error('Error in handleInput loop: ' + e, e);
});
}
cookedPromise.fin(function () {
internallyDisabled = false;
scrollCursorIntoView();
}).done();
clipboardPasteInProgress = false;
},
clipboardPasteInProgress = false, // whether the previous keydown was a CTRL+V or shift+insert
aKeyIsDown = false, // whether a key is currently pressed: if it's not and there is input, then we got a paste via mouse
cursorBlinkRate = (options && options.cursorBlinkRate) || 750, // default: 750 ms
cursorVisible = false,
blinkTimer = null,
renderCursor = function (show) {
if (show !== undefined) cursorVisible = show;
if (blinkTimer !== null) {
clearInterval(blinkTimer);
blinkTimer = null;
}
if (cursorVisible) {
jqContent.find('.cq-con-cursor').addClass('inverted');
if ((typeof cursorBlinkRate === 'number') && (cursorBlinkRate >= 50)) { // have some sense of sanity
blinkTimer = setInterval(function () {
jqContent.find('.cq-con-cursor').toggleClass('inverted');
}, cursorBlinkRate);
}
}
else {
jqContent.find('.cq-con-cursor').removeClass('inverted');
}
},
i, j, a, b, c;
// copy default key handlers
a = handler.keyMaps;
for (i in defaultKeyMappings) {
if (!defaultKeyMappings.hasOwnProperty(i)) continue;
b = defaultKeyMappings[i];
c = {};
a[i] = c;
for (j in b) {
if (!b.hasOwnProperty(j)) continue;
c[j] = b[j];
}
}
a = null;
b = null;
c = null;
// initialize the DOM container
jqElt.empty();
jqElt.append(jqContent);
jqElt.append(jqInput);
jqElt.on('click.console' + instanceNumber, function () {
// set focus to the textarea so we pick up keypresses
jqInput.css('position', 'fixed').focus();
});
jqElt.on('blur.console' + instanceNumber, function () {
jqInput.css('position', 'absolute');
});
// register key handlers
jqInput.on('keydown', function (evt) {
var modifiers = ['' + (0 + evt.shiftKey), '' + (0 + (evt.ctrlKey || evt.metaKey)), '' + (0 + evt.altKey)].join(''),
handlersForModifiers = handler.keyMaps[modifiers],
acceptKeydown = !(inputDisabled || internallyDisabled), // the default value
eventInfo = processKeyboardEvent(evt),
handlerForKeydown = handlersForModifiers && handlersForModifiers[eventInfo.code];
if (acceptKeydown && (typeof handlerForKeydown === 'function')) {
try {
acceptKeydown = handlerForKeydown.apply(handler, [eventInfo]);
}
catch (e) {
// ignore errors, TODO: log errors
}
}
if (!acceptKeydown) {
evt.preventDefault();
evt.stopPropagation();
return false;
}
// paste: CTRL+V or shift+insert
clipboardPasteInProgress = ((modifiers === '010') && (eventInfo.code === 'KeyV')) || ((modifiers === '100') && (eventInfo.code === 'Insert'));
aKeyIsDown = true;
return true;
});
jqInput.on('keyup', function (evt) {
aKeyIsDown = false;
});
jqInput.on('blur focus', function (evt) {
renderCursor(evt.type === 'focus');
});
if ('oninput' in jqInput[0]) {
// the element supports input events, use them in preference to keypress events
jqInput.on('input', function (evt) {
// this event cannot be prevented/cancelled in the DOM scheme of things, but we can simulate prevention, because we have a "detached" display
var t = jqInput.val();
jqInput.val('');
handleInput(t);
});
}
else {
// the element does not support the "modern" input events, so it ought to support the old keypress events
jqInput.on('keypress', function (evt) {
// this event cannot be prevented/cancelled in the DOM scheme of things, but we can simulate prevention, because we have a "detached" display
var keyChar;
try {
if ('char' in evt) keyChar = evt.char;
else if ('charCode' in evt) keyChar = String.fromCharCode(evt.charCode);
else return; // ignore input
handleInput(keyChar);
}
finally {
jqInput.val('');
}
});
}
// emit greeting if provided
if (options && options.greeting) {
lines.push({content: options.greeting, className: 'greeting'});
currentRow++;
}
lines.push({content: '', classNames: []}); // initial input line
render();
return handler;
};
})();