Mercurial > hg > rlgwebd
view termemu.js @ 49:423ef87ddc9b
RLG-Web: delay removing TermSessions until the client is informed.
Add a TermSession.sendq flag that indicates whether a type q message
has been sent to the client. Don't immediately destroy the TermSession
on child exit if the message hasn't been sent.
This is an ugly hack until the TermSession class is separated into an
EventEmitter to handle the PTY and a listening object that handles
communication with the client. That will also allow other clients to
watch the game.
author | John "Elwin" Edwards <elwin@sdf.org> |
---|---|
date | Mon, 11 Jun 2012 09:15:33 -0700 |
parents | 826a7ced69f8 |
children | de01aafd4dd6 |
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 alive: false, /* 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.fixsize(); this.cmove(0, 0); }, resize: function (h, w) { if (this.screen == null) return; if (!(h > 0 && h < 256)) h = this.h; if (!(w > 0 && w < 256)) w = this.w; /* First give all the rows the right number of cells. */ var allrows = Array().concat(this.histbuf.childNodes, this.normbuf.childNodes, this.altbuf.childNodes); for (var i = 0; i < allrows.length; i++) { var row = allrows[i]; for (var j = Math.min(w, this.w); j < Math.max(w, this.w); j++) { if (w < this.w) row.removeChild(row.childNodes.lastChild); else row.appendChild(this.makeCell(' ')); } } this.w = w; /* Now the rows. */ /* Resizing altbuf isn't always necessary. */ /* TODO resize and scrolling region interact in complicated ways that I * don't want to reverse-engineer. For the moment, just don't do that. */ if (h > this.h) { for (var i = this.h; i < h; i++) { this.normbuf.appendChild(this.makeRow()); this.altbuf.appendChild(this.makeRow()); } } else if (h < this.h) { for (var i = h; i < this.h; i++) { this.histbuf.appendChild(this.normbuf.firstChild); if (this.altbuf.firstChild) this.altbuf.removeChild(this.altbuf.firstChild); } } /* Keep it on the bottom */ if (this.scrB == this.h - 1) this.scrB = h - 1; else if (this.scrB >= h) this.scrB = h - 1; if (this.scrT >= h - 1) this.scrT = 0; this.h = h; this.fixsize(); /* If the cursor is now offscreen, cmove()'s sanity checks will fix it. */ this.cmove(null, null); debug(1, "Size is now " + this.w + "x" + this.h); return; }, valign: function () { if (this.screen == this.normbuf) this.inwrap.scrollTop = this.histbuf.clientHeight; }, fixsize: 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. */ /* Check: the cursor might be offscreen if it was resized. */ if (this.c.x != null && this.c.y != null && this.c.x >= 0 && this.c.x < this.w && this.c.y >= 0 && this.c.y < this.h) { 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 == '?')