/* 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 () { "use strict"; 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; renderCursor(); },
                'inputDisable': function () { inputDisabled = true; renderCursor(); },
                '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 && !inputDisabled) {
                    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;
    };
})();