Mercurial > hg > rlgwebd
view termemu.js @ 4:ee22eb9ab009
Client: don't assume the terminal is 24x80.
author | John "Elwin" Edwards <elwin@sdf.org> |
---|---|
date | Mon, 07 May 2012 11:09:14 -0700 |
parents | bd412f63ce0d |
children | 826a7ced69f8 |
line wrap: on
line source
/* termemu.js: a mostly xterm-compatible terminal emulator for a webpage */ /* SELF-HOSTING 2011-09-23 */ // How detailed the debugging should be. var debugSuppress = 1; // Some char values. var csiPre = [63, 62, 33]; var csiPost = [36, 34, 39, 32]; function csiFinal(code) { /* @A-Z */ if (code >= 64 && code <= 90) return true; /* `a-z{| */ if (code >= 96 && code <= 124) return true; return false; } var esc7ctl = [68, 69, 72, 77, 78, 79, 80, 86, 87, 88, 90, 91, 92, 93, 94, 95]; var escSingle = [55, 56, 61, 62, 70, 99, 108, 109, 110, 111, 124, 125, 126]; var escDouble = [32, 35, 37, 40, 41, 42, 43, 45, 46, 47]; var decChars = {96: 0x2666, 97: 0x2592, 102: 0xB0, 103: 0xB1, 106: 0x2518, 107: 0x2510, 108: 0x250C, 109: 0x2514, 110: 0x253C, 111: 0x23BA, 112: 0x23BB, 113: 0x2500, 114: 0x23BC, 115: 0x23BD, 116: 0x251C, 117: 0x2524, 118: 0x2534, 119: 0x252C, 120: 0x2502, 121: 0x2264, 122: 0x2265, 123: 0x03C0, 124: 0x2260, 125: 0xA3, 126: 0xB7}; /* Not everything that should be saved by DECSC has been implemented yet. */ function Cursor(src) { if (src) { this.x = src.x; this.y = src.y; this.bold = src.bold; this.inverse = src.inverse; this.uline = src.uline; this.fg = src.fg; this.bg = src.bg; this.cset = src.cset; } else { this.x = 0; this.y = 0; this.bold = false; this.inverse = false; this.uline = false; this.fg = null; this.bg = null; this.cset = "B"; } return; } // An object representing the terminal emulator. var termemu = { sessid: null, // Session key assigned by the server /* Some elements of the page. */ inwrap: null, // A non-table div wrapping the screen view: null, // The div holding the terminal screen screen: null, // The div representing the active screen area normbuf: null, // The normal screen buffer altbuf: null, // The alternate screen buffer histbuf: null, // The screen history buffer /* Attributes */ w: 0, // Screen width h: 0, // Screen height fgColor: "#b2b2b2", // Default color for text bgColor: "black", // Default background color scrT: 0, // top and bottom of scrolling region scrB: 0, // init() will set this properly c: null, // Contains cursor position and text attributes offedge: false, // Going off the edge doesn't mean adding a new line clearAttrs: function () { /* Make sure to reset ALL attribute properties and NOTHING else. */ this.c.bold = false; this.c.inverse = false; this.c.uline = false; this.c.fg = null; this.c.bg = null; }, saved: null, // saved cursor normc: null, // Stores the normal screen buffer cursor when using altbuf ansicolors: ["#000000", "#b21818", "#18b218", "#b26818", "#1818b2", "#b218b2", "#18b2b2", "#b2b2b2"], brightcolors: ["#686868", "#ff5454", "#54ff54", "#ffff54", "#5454ff", "#ff54ff", "#54ffff", "#ffffff"], cssColor: function (fg) { /* returns a CSS color specification for the text or background */ var n; var fallback; var cube6 = ["00", "5f", "87", "af", "d7", "ff"]; if (this.c.inverse) fg = !fg; if (fg) { n = this.c.fg; fallback = this.fgColor; if (n == null) return fallback; } else { n = this.c.bg; fallback = this.bgColor; if (n == null) return fallback; } if (n < 0) return fallback; else if (n < 8) { if (this.c.bold && fg) return this.brightcolors[n]; else return this.ansicolors[n]; } else if (n < 16) return this.brightcolors[n - 8]; else if (n < 232) { var r = cube6[Math.floor((n - 16) / 36)]; var g = cube6[Math.floor((n - 16) / 6) % 6]; var b = cube6[(n - 16) % 6]; return "#" + r + g + b; } else if (n < 256) { var colstr = ((n - 232) * 10 + 8).toString(16); if (colstr.length < 2) colstr = "0" + colstr; return "#" + colstr + colstr + colstr; } else return fallback; }, // These keyboard-related things don't really belong here. shift: false, shiftp: function () { return this.shift; }, toggleshift: function () { this.shift = !this.shift; }, ctrl: false, ctrlp: function () { return this.ctrl; }, togglectrl: function () { this.ctrl = !this.ctrl; }, init: function (divID, h, w) { /* Makes a div full of character cells. */ if (this.screen != null) return; var owrap = document.getElementById(divID); if (!owrap) return; while (owrap.firstChild != null) owrap.removeChild(owrap.firstChild); this.c = new Cursor(null); /* Set up the sizes. */ var tn; tn = Number(h); if (tn > 0 && tn < 256) this.h = tn; else this.h = 24; tn = Number(w); if (tn > 0 && tn < 256) this.w = tn; else this.w = 80; this.scrB = this.h - 1; /* Create the contents of the terminal div */ this.inwrap = document.createElement("div"); this.inwrap.id = "inwrap"; owrap.appendChild(this.inwrap); var termdiv = document.createElement("div"); termdiv.id = "term"; termdiv.style.fontSize = "12px"; this.inwrap.appendChild(termdiv); /* Set up the screen buffers */ this.histbuf = document.createElement("div"); this.histbuf.id = "histbuf"; termdiv.appendChild(this.histbuf); this.normbuf = document.createElement("div"); this.normbuf.id = "normbuf"; termdiv.appendChild(this.normbuf); for (var row = 0; row < this.h; row++) { this.normbuf.appendChild(this.makeRow()); } this.altbuf = document.createElement("div"); this.altbuf.id = "altbuf"; termdiv.appendChild(this.altbuf); this.altbuf.style.display = "none"; /* altbuf will be filled when it is used. */ /* Attach them. */ this.view = termdiv; this.screen = this.normbuf; this.resize(); this.cmove(0, 0); }, valign: function () { if (this.screen == this.normbuf) this.inwrap.scrollTop = this.histbuf.clientHeight; }, resize: function () { var owrap = document.getElementById("termwrap"); /* Set the height up properly. */ this.inwrap.style.height = this.screen.scrollHeight.toString() + "px"; this.valign(); // Figure out how wide the vertical scrollbar is. var dwid = this.inwrap.offsetWidth - this.inwrap.clientWidth; // And resize accordingly. this.inwrap.style.width = (this.view.scrollWidth + dwid).toString() + "px"; owrap.style.width = this.inwrap.offsetWidth.toString() + "px"; return; }, comseq: [], // Part of an impending control sequence flipCursor: function () { /* Swaps the text and background colors of the active location. */ /* This will change when other cursor styles are supported. */ if (this.c.x != null && this.c.y != null) { var oldcell = this.screen.childNodes[this.c.y].childNodes[this.c.x]; var tempswap = oldcell.style.color; oldcell.style.color = oldcell.style.backgroundColor; oldcell.style.backgroundColor = tempswap; } return; }, saveCursor: function () { this.saved = new Cursor(this.c); return; }, restoreCursor: function () { if (!this.saved) { this.cmove(0, 0); this.c = new Cursor(null); } else { this.cmove(this.saved.y, this.saved.x); this.c = new Cursor(this.saved); } return; }, toAltBuf: function () { if (this.screen == this.altbuf) return; while (this.altbuf.firstChild != null) this.altbuf.removeChild(this.altbuf.firstChild); for (var i = 0; i < this.h; i++) { this.altbuf.appendChild(this.makeRow()); } this.normc = new Cursor(this.c); this.altbuf.style.display = "table-row-group"; this.normbuf.style.display = "none"; this.histbuf.style.display = "none"; this.screen = this.altbuf; debug(0, "Altbuf with charset " + this.c.cset); return; }, toNormBuf: function () { if (this.screen == this.normbuf) return; this.altbuf.style.display = "none"; this.normbuf.style.display = "table-row-group"; this.histbuf.style.display = "table-row-group"; this.screen = this.normbuf; this.valign(); /* The cursor isn't actually at this position in normbuf, but cmove will * flip it anyway. Flip it again to compensate. */ this.flipCursor(); this.cmove(this.normc.y, this.normc.x); this.c = new Cursor(this.normc); }, cmove: function (y, x) { /* Move the cursor. NOTE coords are [row, col] as in curses. */ /* If x or y is null, that coordinate is not altered. */ /* Sanity checks and initializations. */ if (x == null) { if (this.c.x != null) x = this.c.x; else return; } else { this.offedge = false; if (x < 0) x = 0; else if (x >= this.w) x = this.w - 1; } if (y == null) { if (this.c.y != null) y = this.c.y; else return; } else if (y < 0) y = 0; else if (y >= this.h) y = this.h - 1; /* Un-reverse video the current location. */ this.flipCursor(); this.c.x = x; this.c.y = y; /* Reverse-video the new location. */ this.flipCursor(); return; }, historize: function (n) { if (n < 0 || n >= this.screen.childNodes.length) return; var oldrow = this.screen.childNodes[n]; if (this.screen != this.altbuf && this.scrT == 0) { this.histbuf.appendChild(oldrow); } else { this.screen.removeChild(oldrow); } /* These may not be the correct heights... */ this.inwrap.style.height = this.screen.clientHeight.toString() + "px"; this.valign(); }, scroll: function (lines) { if (lines == 0) return; var count; if (lines > 0) count = lines; else count = -lines; this.flipCursor(); while (count > 0) { var blankrow = this.makeRow(); /* Careful with the order */ if (lines > 0) { if (this.scrB == this.h - 1) this.screen.appendChild(blankrow); else this.screen.insertBefore(blankrow, this.screen.childNodes[this.scrB + 1]); this.historize(this.scrT); } else { /* Historize here? */ this.screen.removeChild(this.screen.childNodes[this.scrB]); this.screen.insertBefore(blankrow, this.screen.childNodes[this.scrT]); } count--; } this.valign(); // needed? this.flipCursor(); return; }, newline: function (doReturn) { if (this.c.y == this.scrB) this.scroll(1); else if (this.c.y < this.h - 1) this.cmove(this.c.y + 1, null); /* If the cursor is at the bottom but outside the scrolling region, * nothing can be done. */ if (doReturn) { this.cmove(null, 0); } }, antinewline: function () { if (this.c.y == this.scrT) this.scroll(-1); else if (this.c.y > 0) this.cmove(this.c.y - 1, null); }, advance: function () { if (this.c.x < this.w - 1) this.cmove(null, this.c.x + 1); else { this.offedge = true; } }, placechar: function (str) { if (this.offedge) { this.newline(true); } var nextch = str.charAt(0); var newcell = this.makeCell(nextch); var rowdiv = this.screen.childNodes[this.c.y]; rowdiv.replaceChild(newcell, rowdiv.childNodes[this.c.x]); this.flipCursor(); // The replace removed the cursor. /* Update the cursor. */ this.advance(); }, reset: function () { /* Reset ALL state, hopefully in the right order. */ /* TODO test this and compare it with xterm */ this.toNormBuf(); this.clearAttrs(); this.c.cset = 'B'; this.cmove(0, 0); this.saved = null; this.normc = null; this.scrT = 0; this.scrB = this.h - 1; while (this.histbuf.firstChild != null) { this.histbuf.removeChild(this.histbuf.firstChild); } for (var i = 0; i < this.h; i++) { this.screen.replaceChild(this.makeRow(), this.screen.childNodes[i]); } this.flipCursor(); // make it appear in the new row return; }, write: function (codes) { //dchunk(codes); for (var i = 0; i < codes.length; i++) { /* First see if there's an incomplete command sequence waiting. */ if (this.comseq.length > 0) { if (this.comseq.length == 1 && this.comseq[0] == 27) { /* Just ESC */ if (codes[i] == 55) { /* ESC 7 : save cursor */ this.saveCursor(); this.comseq = []; } else if (codes[i] == 56) { /* ESC 8 : restore cursor */ this.restoreCursor(); this.comseq = []; } else if (codes[i] == 61) { /* ESC = : application keypad */ keyHexCodes.appKeypad(true); this.comseq = []; } else if (codes[i] == 62) { /* ESC > : normal keypad */ keyHexCodes.appKeypad(false); this.comseq = []; } else if (codes[i] == 68) { /* ESC D = IND */ this.newline(false); this.comseq = []; } else if (codes[i] == 69) { /* ESC E = NEL */ this.newline(true); this.comseq = []; } else if (codes[i] == 77) { /* ESC M = RI */ this.antinewline(); this.comseq = []; } else if (codes[i] == 91) { /* ESC [ = CSI */ this.comseq[0] = 155; } else if (codes[i] == 93) { /* ESC [ = OSC */ this.comseq[0] = 157; } else if (codes[i] == 99) { /* ESC c = reset */ this.reset(); this.comseq = []; } else if (escSingle.indexOf(codes[i]) >= 0) { /* An unimplemented two-char sequence. */ debug(1, "Unimplemented sequence ESC " + codes[i].toString(16)); this.comseq = []; } else if (escDouble.indexOf(codes[i]) >= 0) { /* A three-char sequence. */ this.comseq.push(codes[i]); } else { /* Nothing else is implemented yet. */ debug(1, "Unrecognized sequence ESC " + codes[i].toString(16)); this.comseq = []; } } else if (this.comseq.length == 2 && this.comseq[0] == 27) { /* An ESC C N sequence. Not implemented. Doesn't check validity * of N. */ if (this.comseq[1] == 40) { if (codes[i] == 48) { this.c.cset = "0"; debug(0, "Switching to DEC graphics."); } else if (codes[i] == 66) { this.c.cset = "B"; debug(0, "Switching to ASCII."); } else { debug(1, "Unimplemented character set: " + String.fromCharCode(codes[i])); } debug(0, "cset is now: " + this.c.cset); } else debug(1, "Unknown sequence ESC " + String.fromCharCode(this.comseq[1]) + " 0x" + codes[i].toString(16)); this.comseq = []; } else if (this.comseq[0] == 157) { /* Commands beginning with OSC */ /* Check for string terminator */ if (codes[i] == 7 || codes[i] == 156 || (codes[i] == 92 && this.comseq[this.comseq.length - 1] == 27)) { if (codes[i] == 92 && this.comseq[this.comseq.length - 1] == 27) this.comseq.pop(); debug(0, "Got " + (this.comseq.length - 1) + "-byte OSC sequence"); this.oscProcess(); this.comseq = []; } else this.comseq.push(codes[i]); } else if (this.comseq[0] == 155) { /* Commands starting with CSI */ // End at first char that's not numeric ; ? > ! $ " space ' // i.e. letter @ ` lbrace | // ?>! must come directly after CSI // $"'space must come directly before terminator // FIXME put this checking code into csiProcess if (csiPre.indexOf(codes[i]) >= 0) { if (this.comseq.length > 1) { /* Chars in csiPre can only occur right after the CSI */ debug(1, "Invalid CSI sequence: misplaced prefix"); this.comseq = []; } else this.comseq.push(codes[i]); } else if (csiPost.indexOf(this.comseq[this.comseq.length - 1]) >= 0 && !csiFinal(codes[i])) { /* Chars is csiPost must come right before the final char */ debug(1, "Invalid CSI sequence: misplaced postfix"); this.comseq = []; } else if ((codes[i] >= 48 && codes[i] <= 57) || codes[i] == 59 || csiPost.indexOf(codes[i]) >= 0) { /* Numbers and ; can go anywhere */ this.comseq.push(codes[i]); } else if (csiFinal(codes[i])) { this.comseq.push(codes[i]); this.csiProcess(); this.comseq = []; } else { debug(1, "Invalid CSI sequence: unknown code " + codes[i].toString(16)); this.comseq = []; } } else { debug(1, "Unknown sequence with " + this.comseq[0].toString(16)); this.comseq = []; } continue; } /* Treat it as a single character. */ if (codes[i] == 5) { sendback("06"); } else if (codes[i] == 7) { /* bell */ bell(true); } else if (codes[i] == 8) { /* backspace */ if (this.offedge) this.offedge = false; else if (this.c.x > 0) this.cmove(null, this.c.x - 1); } else if (codes[i] == 9) { /* tab */ var xnew; if (this.c.x < this.w - 1) { xnew = 8 * (Math.floor(this.c.x / 8) + 1); if (xnew >= this.w) xnew = this.w - 1; this.cmove(null, xnew); } else { this.offedge = true; } } else if (codes[i] >= 10 && codes[i] <= 12) { /* newline, vertical tab, form feed */ if (this.offedge) this.newline(true); else this.newline(false); } else if (codes[i] == 13) { /* carriage return \r */ this.cmove(null, 0); } else if (codes[i] == 14) { /* shift out */ // Currently assuming that G1 is DEC Special & Line Drawing this.c.cset = "0"; debug(0, "Using DEC graphics charset."); } else if (codes[i] == 15) { /* shift in */ // Currently assuming that G0 is ASCII this.c.cset = "B"; debug(0, "Using ASCII charset."); } else if (codes[i] == 27) { /* escape */ this.comseq.push(codes[i]); } else if (codes[i] < 32 || (codes[i] >= 127 && codes[i] < 160)) { /* Some kind of control character. */ debug(1, "Unprintable character 0x" + codes[i].toString(16)); } else { /* If it's ASCII, it's printable; take a risk on anything higher */ if ((this.c.cset == "0") && (codes[i] in decChars)) { // DEC special character set this.placechar(String.fromCharCode(decChars[codes[i]])); } else { this.placechar(String.fromCharCode(codes[i])); } } } return; }, csiProcess: function () { /* Processes the CSI sequence in this.comseq */ var c = this.comseq[this.comseq.length - 1]; if (this.comseq[0] != 155 || !csiFinal(c)) return; var comstr = ""; for (var i = 1; i < this.comseq.length; i++) comstr += String.fromCharCode(this.comseq[i]); debug(0, "CSI sequence: " + comstr); var reCSI = /^([>?!])?([0-9;]*)([ "$'])?([A-Za-z@`{|])$/; var matchCSI = comstr.match(reCSI); if (!matchCSI) { debug(1, "Unrecognized CSI sequence: " + comstr); return; } var prefix = null; if (matchCSI[1]) prefix = matchCSI[1]; var postfix = null; if (matchCSI[3]) postfix = matchCSI[3]; var params = []; if (matchCSI[2]) { var numstrs = matchCSI[2].split(";"); for (var i = 0; i < numstrs.length; i++) { if (numstrs[i]) params.push(Number(numstrs[i])); else params.push(null); } } /* Useful if expecting a single parameter which is a count. */ var count = 1; if (params[0]) count = params[0]; /* The final character determines the action. */ if (c == 64) { /* @ - insert spaces at cursor */ if (prefix || postfix) { debug(1, "Invalid CSI @ sequence: " + comstr); return; } /* The cursor stays still, but characters move out from under it. */ this.flipCursor(); var rowdiv = this.screen.childNodes[this.c.y]; var newspace; while (count > 0) { newspace = this.makeCell(' '); rowdiv.insertBefore(newspace, rowdiv.childNodes[this.c.x]); rowdiv.removeChild(rowdiv.lastChild); count--; } /* Finally, put the cursor back. */ this.flipCursor(); } else if (c >= 65 && c <= 71) { /* A - up, B - down, C - forward, D - backward */ /* E - next line, F - previous line, G - to column */ if (prefix || postfix) { debug(1, "Invalid CSI sequence: " + comstr); return; } /* These may be out of range, but cmove will take care of that. */ if (c == 65) this.cmove(this.c.y - count, null); else if (c == 66) this.cmove(this.c.y + count, null); else if (c == 67) this.cmove(null, this.c.x + count); else if (c == 68) this.cmove(null, this.c.x - count); else if (c == 69) this.cmove(this.c.y + count, 0); else if (c == 70) this.cmove(this.c.y - count, 0); else if (c == 71) this.cmove(null, count - 1); } else if (c == 72) { /* H - move */ var x = 0; var y = 0; if (prefix || postfix) { debug(1, "Invalid CSI H sequence: " + comstr); return; } if (params[0]) y = params[0] - 1; if (params[1]) x = params[1] - 1; if (y >= this.h) y = this.h - 1; if (x >= this.w) x = this.w - 1; debug(0, "Moving to row " + y + ", col " + x); this.cmove(y, x); } else if (c == 73) { /* I - move forward by tabs */ var x = this.c.x; if (prefix || postfix) { debug(1, "Invalid CSI I sequence: " + comstr); return; } while (count > 0) { x = 8 * (Math.floor(x / 8) + 1); if (x >= this.w) { x = this.w - 1; break; } count--; } this.cmove(null, x); } else if (c == 74) { /* J - erase display */ var start; var end; var cols; if (prefix == '?') debug(1, "Warning: CSI ?J not implemented"); else if (prefix || postfix) { debug(1, "Invalid CSI J sequence: " + comstr); return; } if (!params[0]) { /* Either 0 or not given */ start = this.c.y + 1; end = this.h - 1; cols = 1; } else if (params[0] == 1) { start = 0; end = this.c.y - 1; cols = -1; } else if (params[0] == 2) { start = 0; end = this.h - 1; cols = 0; } else { debug(1, "Unimplemented parameter in CSI J sequence: " + comstr); return; } for (var nrow = start; nrow <= end; nrow++) { this.screen.replaceChild(this.makeRow(), this.screen.childNodes[nrow]); } if (cols != 0) { /* Otherwise, the whole screen was erased and the active row doesn't * need special treatment. */ var cursrow = this.screen.childNodes[this.c.y]; for (var ncol = this.c.x; ncol >= 0 && ncol < this.w; ncol += cols) { cursrow.replaceChild(this.makeCell(' '), cursrow.childNodes[ncol]); } } this.offedge = false; /* Always flip after replacing the active position. */ this.flipCursor(); } else if (c == 75) { /* K - erase line */ /* The ? is for an erase method that respects an "uneraseable" attribute, * which isn't implemented, so the methods are equivalent for now. */ var start; var end; if (prefix == '?') debug(1, "Warning: CSI ?K not implemented"); else if (prefix || postfix) { debug(1, "Invalid CSI K sequence: " + comstr); return; } /* 0 (default): right, 1: left, 2: all. Include cursor position. */ if (params[0] == 1) { start = 0; end = this.c.x; } else if (params[0] == 2) { start = 0; end = this.w - 1; } else { start = this.c.x; end = this.w - 1; }