diff --git a/core/ansi_escape_parser.js b/core/ansi_escape_parser.js index 89ac9a99..65ffe220 100644 --- a/core/ansi_escape_parser.js +++ b/core/ansi_escape_parser.js @@ -4,6 +4,7 @@ var events = require('events'); var util = require('util'); var miscUtil = require('./misc_util.js'); +var ansi = require('./ansi_term.js'); exports.ANSIEscapeParser = ANSIEscapeParser; @@ -18,8 +19,6 @@ function ANSIEscapeParser(options) { this.flags = 0x00; this.scrollBack = 0; - - options = miscUtil.valueWithDefault(options, { mciReplaceChar : '', termHeight : 25, @@ -29,6 +28,12 @@ function ANSIEscapeParser(options) { this.mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ''); this.termHeight = miscUtil.valueWithDefault(options.termHeight, 25); this.termWidth = miscUtil.valueWithDefault(options.termWidth, 80); + + function saveLastColor() { + self.lastFlags = self.flags; + self.lastFgCololr = self.fgColor; + self.lastBgColor = self.bgColor; + } function getArgArray(array) { var i = array.length; @@ -125,7 +130,8 @@ function ANSIEscapeParser(options) { function getProcessedMCI(mci) { if(self.mciReplaceChar.length > 0) { - return new Array(mci.length + 1).join(self.mciReplaceChar); + var eraseColor = ansi.sgr(self.lastFlags, self.lastFgColor, self.lastBgColor); + return eraseColor + new Array(mci.length + 1).join(self.mciReplaceChar); } else { return mci; } @@ -254,6 +260,8 @@ function ANSIEscapeParser(options) { // set graphic rendition case 'm' : + saveLastColor(); + for(i = 0, len = args.length; i < len; ++i) { arg = args[i]; if(0x00 === arg) { @@ -280,6 +288,9 @@ function ANSIEscapeParser(options) { break; } } + + this.resetColor(); + saveLastColor(); } util.inherits(ANSIEscapeParser, events.EventEmitter); \ No newline at end of file diff --git a/core/ansi_term.js b/core/ansi_term.js index 4b7b57bd..1edc7e3c 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -16,7 +16,7 @@ var miscUtil = require('./misc_util.js'); exports.sgr = sgr; exports.clearScreen = clearScreen; -exports.clearScreenGoHome = clearScreenGoHome; +exports.resetScreen = resetScreen; exports.normal = normal; exports.goHome = goHome; exports.disableVT100LineWrapping = disableVT100LineWrapping; @@ -40,13 +40,18 @@ var CONTROL = { prevLine : 'F', horizAbsolute : 'G', eraseData : 'J', + eraseLine : 'K', + insertLine : 'L', + deleteLine : 'M', scrollUp : 'S', scrollDown : 'T', savePos : 's', restorePos : 'u', queryPos : '6n', goto : 'H', // row Pr, column Pc -- same as f - gotoAlt : 'f' // same as H + gotoAlt : 'f', // same as H + + emulationSpeed : '*r' // Set output emulation speed. See cterm.txt }; /* @@ -255,8 +260,8 @@ function clearScreen() { return exports.eraseData(2); } -function clearScreenGoHome() { - return exports.goto(1,1) + exports.eraseData(2); +function resetScreen() { + return exports.goHome() + exports.eraseData(2); } function normal() { diff --git a/core/art.js b/core/art.js index f57888bd..fcfd4254 100644 --- a/core/art.js +++ b/core/art.js @@ -418,7 +418,7 @@ function display(art, options, cb) { parser.on('mci', function onMCI(mciCode, args) { if(mci[mciCode]) { - mci[mciCode].altColor = { + mci[mciCode].focusColor = { fg : parser.fgColor, bg : parser.bgColor, flags : parser.flags, @@ -430,7 +430,9 @@ function display(art, options, cb) { fg : parser.fgColor, bg : parser.bgColor, flags : parser.flags, - } + }, + code : mciCode.substr(0, 2), + id : mciCode.substr(2, 1), // :TODO: This NEEDs to read 01-99 }; mciPosQueue.push(mciCode); diff --git a/core/client.js b/core/client.js index b2bc01a7..ae959201 100644 --- a/core/client.js +++ b/core/client.js @@ -10,6 +10,57 @@ var logger = require('./logger.js'); exports.Client = Client; +//var ANSI_CONTROL_REGEX = /(?:(?:\u001b\[)|\u009b)(?:(?:[0-9]{1,3})?(?:(?:;[0-9]{0,3})*)?[A-M|f-m])|\u001b[A-M]/g; + +// :TODO: Move all of the key stuff to it's own module +var ANSI_KEY_NAME_MAP = { + 0x08 : 'backspace', + 0x09 : 'tab', + 0x7f : 'del', + 0x1b : 'esc', + 0x0d : 'enter', +}; + +var ANSI_KEY_CSI_NAME_MAP = { + 0x40 : 'insert', // @ + 0x41 : 'up arrow', // A + 0x42 : 'down arrow', // B + 0x43 : 'right arrow', // C + 0x44 : 'left arrow', // D + + 0x48 : 'home', // H + 0x4b : 'end', // K + + 0x56 : 'page up', // V + 0x55 : 'page down', // U +}; + +var ANSI_F_KEY_NAME_MAP_1 = { + 0x50 : 'F1', + 0x51 : 'F2', + 0x52 : 'F3', + 0x53 : 'F4', + 0x74 : 'F5', +}; + +var ANSI_F_KEY_NAME_MAP_2 = { + // rxvt + 11 : 'F1', + 12 : 'F2', + 13 : 'F3', + 14 : 'F4', + 15 : 'F5', + + // SyncTERM + 17 : 'F6', + 18 : 'F7', + 19 : 'F8', + 20 : 'F9', + 21 : 'F10', + 23 : 'F11', + 24 : 'F12', +}; + function Client(input, output) { stream.call(this); @@ -19,19 +70,80 @@ function Client(input, output) { this.output = output; this.term = new term.ClientTerminal(this.output); - self.on('data', function onData(data) { - console.log('data: ' + data.length); + self.on('data', function onData1(data) { + console.log(data); + + onData(data); handleANSIControlResponse(data); }); function handleANSIControlResponse(data) { - console.log(data); + //console.log(data); ansi.forEachControlCode(data, function onControlResponse(name, params) { var eventName = 'on' + name[0].toUpperCase() + name.substr(1); console.log(eventName + ': ' + params); self.emit(eventName, params); }); } + + // + // Peek at |data| and emit for any specialized handling + // such as ANSI control codes or user/keyboard input + // + function onData(data) { + var len = data.length; + var c; + var name; + + if(1 === len) { + c = data[0]; + + if(0x00 === c) { + // ignore single NUL + return; + } + + name = ANSI_KEY_NAME_MAP[c]; + if(name) { + self.emit('special key', name); + self.emit('key press', data, true); + } else { + self.emit('key press', data, false); + } + } + + if(0x1b !== data[0]) { + return; + } + + if(3 === len) { + if(0x5b === data[1]) { + name = ANSI_KEY_CSI_NAME_MAP[data[2]]; + if(name) { + self.emit('special key', name); + self.emit('key press', data, true); + } + } else if(0x4f === data[1]) { + name = ANSI_F_KEY_NAME_MAP_1[data[2]]; + if(name) { + self.emit('special key', name); + self.emit('key press', data, true); + } + } + } else if(5 === len && 0x5b === data[1] && 0x7e === data[4]) { + var code = parseInt(data.slice(2,4), 10); + + if(!isNaN(code)) { + name = ANSI_F_KEY_NAME_MAP_2[code]; + if(name) { + self.emit('special key', name); + self.emit('key press', data, true); + } + } + } else if(len > 4 && len < 11 && 0x52 === data[len]) { + // :TODO: Look for various DSR responses such as cursor position + } + } } require('util').inherits(Client, stream); @@ -48,11 +160,9 @@ Client.prototype.destroySoon = function () { return this.output.destroySoon.apply(this.output, arguments); }; -Client.prototype.getch = function(cb) { - this.input.once('data', function onData(data) { - // :TODO: needs work. What about F keys and the like? - assert(data.length === 1); - cb(data); +Client.prototype.waitForKeyPress = function(cb) { + this.once('key press', function onKeyPress(kp) { + cb(kp); }); }; diff --git a/core/string_util.js b/core/string_util.js index 5696ce65..c5ba5685 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -1,7 +1,11 @@ /* jslint node: true */ 'use strict'; +var miscUtil = require('./misc_util.js'); + + exports.stylizeString = stylizeString; +exports.pad = pad; // :TODO: create Unicode verison of this var VOWELS = [ 'a', 'e', 'i', 'o', 'u' ]; @@ -25,26 +29,31 @@ function stylizeString(s, style) { var i; var stylized = ''; - switch(style) { + switch(style) { // UPPERCASE + case 'upper' : case 'U' : return s.toUpperCase(); // lowercase + case 'lower' : case 'l' : return s.toLowerCase(); // Proper Case + case 'proper' : case 'P' : return s.replace(/\w\S*/g, function onProperCaseChar(t) { return t.charAt(0).toUpperCase() + t.substr(1).toLowerCase(); }); // fIRST lOWER + case 'first lower' : case 'f' : return s.replace(/\w\S*/g, function onFirstLowerChar(t) { return t.charAt(0).toLowerCase() + t.substr(1).toUpperCase(); }); // SMaLL VoWeLS + case 'small vowels' : case 'v' : for(i = 0; i < len; ++i) { c = s[i]; @@ -57,6 +66,7 @@ function stylizeString(s, style) { return stylized; // bIg vOwELS + case 'big vowels' : case 'V' : for(i = 0; i < len; ++i) { c = s[i]; @@ -69,9 +79,11 @@ function stylizeString(s, style) { return stylized; // Small i's: DEMENTiA + case 'small i' : case 'i' : return s.toUpperCase().replace('I', 'i'); // mIxeD CaSE (random upper/lower) + case 'mixed' : case 'M' : for(i = 0; i < len; i++) { if(Math.random() < 0.5) { @@ -83,6 +95,7 @@ function stylizeString(s, style) { return stylized; // l337 5p34k + case 'l33t' : case '3' : for(i = 0; i < len; ++i) { c = SIMPLE_ELITE_MAP[s[i].toLowerCase()]; @@ -91,5 +104,34 @@ function stylizeString(s, style) { return stylized; } + return s; +} + +// Based on http://www.webtoolkit.info/ +function pad(s, len, padChar, dir) { + len = miscUtil.valueWithDefault(len, 0); + padChar = miscUtil.valueWithDefault(padChar, ' '); + dir = miscUtil.valueWithDefault(dir, 'right'); + + var padlen = len - s.length; + + switch(dir) { + case 'left' : + s = new Array(padlen).join(padChar) + s; + break; + + case 'both' : + var right = Math.ceil(padlen) / 2; + var left = padlen - right; + s = new Array(left + 1).join(padChar) + s + new Array(right + 1).join(padChar); + break; + + case 'right' : + s = s + new Array(padlen).join(padChar); + break; + + default : break; + } + return s; } \ No newline at end of file diff --git a/core/view.js b/core/view.js index d367c257..b3293b5e 100644 --- a/core/view.js +++ b/core/view.js @@ -3,42 +3,241 @@ var util = require('util'); var ansi = require('./ansi_term.js'); +var miscUtil = require('./misc_util.js'); +var strUtil = require('./string_util.js'); +var assert = require('assert'); +var events = require('events'); +var logger = require('./logger.js'); exports.View = View; exports.LabelView = LabelView; +exports.TextEditView = TextEditView; + +exports.ViewsController = ViewsController; function View(client) { + events.EventEmitter.call(this); + var self = this; - console.log('View ctor'); - - this.client = client; - -// this.width = width; -// this.height = height; + this.client = client; + this.acceptsFocus = false; + this.acceptsKeys = false; } -// :TODO: allow pos[] or x, y -View.prototype.draw = function(x, y) { +util.inherits(View, events.EventEmitter); + +View.prototype.place = function(pos) { + // + // We allow [x, y], { x : x, y : y }, or (x, y) + // + if(util.isArray(pos)) { + this.x = pos[0]; + this.y = pos[1]; + } else if(pos.x && pos.y) { + this.x = pos.x; + this.y = pos.y; + } else if(2 === arguments.length) { + var x = parseInt(arguments[0], 10); + var y = parseInt(arguments[1], 10); + if(!isNaN(x) && !isNaN(y)) { + this.x = x; + this.y = y; + } + } + + assert(this.x > 0 && this.x < this.client.term.termHeight); + assert(this.y > 0 && this.y < this.client.term.termWidth); + + this.client.term.write(ansi.goto(this.x, this.y)); }; -function LabelView(client, text, width) { +View.prototype.setFocus = function(focused) { + assert(this.x); + assert(this.y); +}; + +View.prototype.getColor = function() { + return this.options.color; +}; + +View.prototype.getFocusColor = function() { + return this.options.focusColor || this.getColor(); +}; + +function LabelView(client, text, options) { View.call(this, client); var self = this; - this.text = text; - this.width = width || text.length; + if(options) { + if(options.maxWidth) { + text = text.substr(0, options.maxWidth); + } + text = strUtil.stylizeString(text, options.style); + } + + this.value = text; + this.height = 1; + this.width = this.value.length; } util.inherits(LabelView, View); -LabelView.prototype.draw = function(x, y) { - LabelView.super_.prototype.draw.call(this, x, y); +LabelView.prototype.place = function(pos) { + LabelView.super_.prototype.place.apply(this, arguments); + + this.client.term.write(this.value); +}; - this.client.term.write(ansi.goto(x, y)); - this.client.term.write(this.text); +/////////////////////////////////////////////////////////////////////////////// + +var INTERACTIVE_VIEW_DEFAULT_SPECIAL_KEYSET = { + enter : [ 'enter' ], + exit : [ 'esc' ], + backspace : [ 'backspace', 'del' ], + next : [ 'tab' ], +}; + +function InteractiveView(client, options) { + View.call(this, client); + + this.acceptsFocus = true; + this.acceptsKeys = true; + + if(options) { + this.options = options; + } else { + this.options = { + }; + } + + this.options.specialKeySet = miscUtil.valueWithDefault( + options.specialKeySet, INTERACTIVE_VIEW_DEFAULT_SPECIAL_KEYSET + ); + + this.isSpecialKeyFor = function(checkFor, specialKey) { + return this.options.specialKeySet[checkFor].indexOf(specialKey) > -1; + }; + + this.backspace = function() { + this.client.term.write('\b \b'); + }; +} + +util.inherits(InteractiveView, View); + +InteractiveView.prototype.setFocus = function(focused) { + InteractiveView.super_.prototype.setFocus.call(this, focused); + + this.hasFocus = focused; +}; + +InteractiveView.prototype.setNextView = function(id) { + this.nextId = id; +}; + + +var TEXT_EDIT_INPUT_TYPES = [ + 'none', // :TODO: TextEditView -> TextView (optional focus/editable) + 'text', + 'password', + 'upper', + 'lower', +]; + + +function TextEditView(client, options) { + InteractiveView.call(this, client, options); + + if(!options) { + this.options.multiLine = false; + } + + this.options.inputType = miscUtil.valueWithDefault(this.options.inputType, 'text'); + assert(TEXT_EDIT_INPUT_TYPES.indexOf(this.options.inputType) > -1); + + if('password' === this.options.inputType) { + this.options.inputMaskChar = miscUtil.valueWithDefault(this.options.inputMaskChar, '*').substr(0,1); + } + + this.value = miscUtil.valueWithDefault(options.defaultValue, ''); + // :TODO: hilight, text, etc., should come from options or default for theme if not provided + + // focus=fg + bg + // standard=fg +bg + +} + +util.inherits(TextEditView, InteractiveView); + + +TextEditView.prototype.place = function(pos) { + TextEditView.super_.prototype.place.apply(this, arguments); + + if(!this.options.maxWidth) { + this.options.maxWidth = this.client.term.termWidth - this.x; + } + + this.width = this.options.maxWidth; +}; + +TextEditView.prototype.setFocus = function(focused) { + TextEditView.super_.prototype.setFocus.call(this, focused); + + this.client.term.write(ansi.goto(this.x, this.y)); + this.redraw(); + this.client.term.write(ansi.goto(this.x, this.y + this.value.length)); +}; + +TextEditView.prototype.redraw = function() { + var color = this.hasFocus ? this.getFocusColor() : this.getColor(); + + this.client.term.write(ansi.sgr(color.flags, color.fg, color.bg)); + this.client.term.write(strUtil.pad(this.value, this.width)); +}; + +TextEditView.prototype.onKeyPressed = function(k, isSpecial) { + assert(this.hasFocus); + + if(isSpecial) { + return; // handled via onSpecialKeyPressed() + } + + if(this.value.length < this.options.maxWidth) { + + k = strUtil.stylizeString(k, this.options.inputType); + + this.value += k; + + if('password' === this.options.inputType) { + this.client.term.write(this.options.inputMaskChar); + } else { + this.client.term.write(k); + } + } +}; + +TextEditView.prototype.onSpecialKeyPressed = function(keyName) { + assert(this.hasFocus); + + console.log(keyName); + + if(this.isSpecialKeyFor('backspace', keyName)) { + if(this.value.length > 0) { + this.value = this.value.substr(0, this.value.length - 1); + this.backspace(); + } + } else if(this.isSpecialKeyFor('enter', keyName)) { + if(this.options.multiLine) { + + } else { + this.emit('action', 'accepted'); + } + } else if(this.isSpecialKeyFor('next', keyName)) { + this.emit('action', 'next'); + } }; @@ -48,4 +247,167 @@ function MenuView(options) { function VerticalMenuView(options) { -} \ No newline at end of file +} + +/////////////////////////////////////////////////////// +// :TODO: Move to view_controller.js +function ViewsController(client) { + events.EventEmitter.call(this); + + var self = this; + + this.views = {}; + this.client = client; + + client.on('key press', function onKeyPress(k, isSpecial) { + if(self.focusedView && self.focusedView.acceptsKeys) { + self.focusedView.onKeyPressed(k, isSpecial); + } + }); + + client.on('special key', function onSpecialKey(keyName) { + if(self.focusedView && self.focusedView.acceptsKeys) { + self.focusedView.onSpecialKeyPressed(keyName); + } + }); + + this.onViewAction = function(action) { + console.log(action + '@ ' + this.id); + + self.emit('action', { id : this.id, action : action }); + + if('accepted' === action) { + self.nextFocus(); + } else if('next' === action) { + self.nextFocus(); + } + }; + +} + +util.inherits(ViewsController, events.EventEmitter); + +ViewsController.prototype.addView = function(viewInfo) { + viewInfo.view.id = viewInfo.id; + + this.views[viewInfo.id] = { + view : viewInfo.view, + pos : viewInfo.pos + }; + + viewInfo.view.place(viewInfo.pos); +}; + +ViewsController.prototype.viewExists = function(id) { + return id in this.views; +}; + +ViewsController.prototype.getView = function(id) { + return this.views[id].view; +}; + +ViewsController.prototype.switchFocus = function(id) { + var view = this.getView(id); + + if(!view) { + logger.log.warn('Invalid view', { id : id }); + return false; + } + + if(!view.acceptsFocus) { + logger.log.warn('View does not accept focus', { id : id }); + return false; + } + + this.focusedView = view; + view.setFocus(true); +}; + +ViewsController.prototype.nextFocus = function() { + var nextId = this.focusedView.nextId; + + this.focusedView.setFocus(false); + + if(nextId > 0) { + this.switchFocus(nextId); + } else { + this.switchFocus(this.firstId); + } +}; + +ViewsController.prototype.loadFromMCIMap = function(mciMap) { + var factory = new MCIViewFactory(this.client); + var view; + var mci; + + for(var entry in mciMap) { + mci = mciMap[entry]; + view = factory.createFromMCI(mci); + + if(view) { + this.addView({ + id : mci.id, + view : view, + pos : mci.position + }); + + view.on('action', this.onViewAction); + } + } +}; + +ViewsController.prototype.setViewOrder = function(order) { + var idOrder = []; + + if(order) { + // :TODO: + } else { + for(var id in this.views) { + idOrder.push(id); + } + // :TODO: simply sort + console.log(idOrder); + this.firstId = idOrder[0]; + } + + var view; + for(var i = 0; i < idOrder.length - 1; ++i) { + view = this.getView(idOrder[i]); + if(view) { + view.setNextView(idOrder[i + 1]); + } + } +}; + +/////////////////////////////////////////////////// + +function MCIViewFactory(client, mci) { + this.client = client; +} + +MCIViewFactory.prototype.createFromMCI = function(mci) { + assert(mci.code); + assert(mci.id > 0); + + var view; + var options = {}; + + switch(mci.code) { + case 'EV' : + if(mci.args.length > 0) { + options.maxWidth = mci.args[0]; + } + + if(mci.args.length > 1) { + + } + + options.color = mci.color; + options.focusColor = mci.focusColor; + + view = new TextEditView(this.client, options); + break; + } + + return view; +}; \ No newline at end of file diff --git a/mods/art/MATRIX_TEST1.ANS b/mods/art/MATRIX_TEST1.ANS index 75e9b2f4..5d909d31 100644 Binary files a/mods/art/MATRIX_TEST1.ANS and b/mods/art/MATRIX_TEST1.ANS differ diff --git a/mods/art/MCI_TEST1.ANS b/mods/art/MCI_TEST1.ANS new file mode 100644 index 00000000..4bbc89c1 Binary files /dev/null and b/mods/art/MCI_TEST1.ANS differ diff --git a/mods/art/MCI_TEST2.ANS b/mods/art/MCI_TEST2.ANS new file mode 100644 index 00000000..86b0138e Binary files /dev/null and b/mods/art/MCI_TEST2.ANS differ diff --git a/mods/art/MCI_TEST3.ANS b/mods/art/MCI_TEST3.ANS new file mode 100644 index 00000000..b7a94830 Binary files /dev/null and b/mods/art/MCI_TEST3.ANS differ diff --git a/mods/matrix.js b/mods/matrix.js index 8f2b7908..90ce7e74 100644 --- a/mods/matrix.js +++ b/mods/matrix.js @@ -6,6 +6,8 @@ var ansi = require('../core/ansi_term.js'); var lineEditor = require('../core/line_editor.js'); var art = require('../core/art.js'); +var view = require('../core/view.js'); + exports.moduleInfo = { name : 'Matrix', desc : 'Standardish Matrix', @@ -17,19 +19,39 @@ exports.entryPoint = entryPoint; function entryPoint(client) { var term = client.term; - term.write(ansi.clearScreenGoHome()); + term.write(ansi.resetScreen()); + + //------------- + /* + client.on('position', function onPos(pos) { + console.log(pos); + }); + + term.write('Hello, world!'); + term.write(ansi.queryPos()); + term.write(ansi.goto(5,5)); + term.write('Yehawww a bunch of text incoming.... maybe that is what breaks it... hrm... who knows.\nHave to do more testing ;(\n'); + term.write(ansi.queryPos()); + return; + */ + + //------------- // :TODO: types, random, and others? could come from conf.mods.matrix or such //art.getArt('SO-CC1.ANS'/* 'MATRIX'*/, { types: ['.ans'], random: true}, function onArt(err, theArt) { - art.getArt('DM-ENIG2-MATRIX.ANS', {}, function onArt(err, theArt) { + art.getArt('MCI_TEST3.ANS', /*'MATRIX_TEST1.ANS'*/ {}, function onArt(err, theArt) { if(!err) { + art.display(theArt.data, { client : client, mciReplaceChar : ' ' }, function onArtDisplayed(err, mci) { if(err) { - return; + return; } - console.log(mci); + var vc = new view.ViewsController(client); + vc.loadFromMCIMap(mci); + vc.setViewOrder(); + vc.switchFocus(1); }); } });