From 1e65d71bde45eb3c0f62a7e91214de8ee026c9e1 Mon Sep 17 00:00:00 2001 From: Joe Walker Date: Thu, 9 Dec 2010 18:22:44 +0000 Subject: [PATCH] make the command line ui process hints at least to a basic extent --- plugins/cockpit/lib/cli.js | 46 ++- plugins/cockpit/lib/ui/plain.css | 181 ++++++++++- plugins/cockpit/lib/ui/plain.js | 165 +++++++++- plugins/cockpit/lib/ui/plainRow.html | 47 +++ plugins/pilot/lib/canon.js | 32 +- plugins/pilot/lib/commands/settings.js | 14 +- plugins/pilot/lib/domtemplate.js | 406 +++++++++++++++++++++++++ plugins/pilot/lib/settings.js | 6 +- plugins/pilot/lib/types.js | 22 +- plugins/pilot/lib/types/basic.js | 55 ++-- plugins/pilot/lib/types/command.js | 11 +- plugins/pilot/lib/types/settings.js | 16 +- 12 files changed, 911 insertions(+), 90 deletions(-) create mode 100644 plugins/cockpit/lib/ui/plainRow.html create mode 100644 plugins/pilot/lib/domtemplate.js diff --git a/plugins/cockpit/lib/cli.js b/plugins/cockpit/lib/cli.js index 027ba9f8..24a25a35 100644 --- a/plugins/cockpit/lib/cli.js +++ b/plugins/cockpit/lib/cli.js @@ -70,6 +70,50 @@ function Hint(status, message, start, end) { } Hint.prototype = { }; +/** + * Loop over the array of hints finding the one we should display. + * @param hints array of hints + */ +Hint.worst = function(hints, cursor) { + if (hints.length === 0) { + return undefined; + } + // Calculate 'distance from cursor' + if (cursor !== undefined) { + hints.forEach(function(hint) { + if (cursor < hint.start) { + hint.distance = hint.start - cursor; + } + else { + if (cursor > hint.end) { + hint.distance = cursor - hint.end; + } + else { + hint.distance = 0; + } + } + }, this); + } + // Sort + hints.sort(function(hint1, hint2) { + // Compare first based on distance from cursor + if (cursor !== undefined) { + var diff = hint1.distance - hint2.distance; + if (diff != 0) { + return diff; + } + } + // otherwise go with hint severity + return hint1 - hint2; + }); + // tidy-up + if (cursor !== undefined) { + hints.forEach(function(hint) { + delete hint.distance; + }, this); + } + return hints[0]; +}; exports.Hint = Hint; /** @@ -816,6 +860,4 @@ function documentCommand(command) { exports.documentCommand = documentCommand; - - }); diff --git a/plugins/cockpit/lib/ui/plain.css b/plugins/cockpit/lib/ui/plain.css index 85c55ff1..d6a59f54 100644 --- a/plugins/cockpit/lib/ui/plain.css +++ b/plugins/cockpit/lib/ui/plain.css @@ -1,2 +1,181 @@ -#cockpit { color: red; } +/* Command line completion */ +.cptCompletion { + color: #666; + padding: 0 0 5px 2px; + position: absolute; + z-index: -1000; +} + +.cptHints { + color: #000; + position: absolute; + border: 1px solid rgba(230, 230, 230, 0.8); + background: rgba(250, 250, 250, 0.8); + -moz-border-radius-topleft: 10px; + -moz-border-radius-topright: 10px; + border-top-left-radius: 10px; border-top-right-radius: 10px; + z-index: 1000; + padding: 8px; +} + +.cptCompletion.VALID { background: rgba(210, 255, 210, 1); } +.cptCompletion.INCOMPLETE { background: rgba(255, 230, 210, 1); } +.cptCompletion.INVALID { background: rgba(255, 210, 210, 1); } + +.cptCompletion span { color: #FFF; } +.cptCompletion span.INCOMPLETE { text-shadow: 0px 0px 2px #ff8800; } +.cptCompletion span.INVALID { text-shadow: 0px 0px 5px #ff0000; } + + +#cockpit, .cptCompletion { + font-family: consolas, courier, monospace; + font-size: 120%; +} + +#cockpit { + background: transparent; + border: none; outline: none; +} + + +.cptHints article { + margin-left: 25px; + padding: 8px; + z-index: 1; + color: @text; + display: block; + border: 2px @border_bg solid; + border-bottom: 0; + font-size: 90%; + -moz-border-top-colors: @border_bg @border_fg; + -moz-border-left-colors: @border_bg @border_fg; + -moz-border-right-colors: @border_bg @bg; + -moz-border-radius-topleft: 10px; + -moz-border-radius-topright: 10px; + border-top-left-radius: 10px; border-top-right-radius: 10px; + background: @input_bg2; + background: -moz-linear-gradient(top, @input_bg_light, @input_bg); + background: -webkit-gradient(linear, left top, left bottom, from(@input_bg_light), to(@input_bg)); + -webkit-box-shadow: rgba(0, 0, 0, 0.5) 0px 1px 10px 3px; + -moz-box-shadow: rgba(0, 0, 0, 0.5) 0px 1px 10px 3px; + max-width: 500px; +} +.cptHints article h1 { font-size: 110%; text-align: center; } +.cptHints article h2 { font-size: 95%; } +.cptHints article h3 { font-size: 90%; } +.cptHints article table th, +.cptHints article label { color: @hi_text; } +.cptHints article h1, +.cptHints article h2, +.cptHints article h3 { margin: 2px 0 1px 0; color: @hi_text; } +.cptHints article p, +.cptHints article pre, + + + + + +/* Global visual styles */ +.cptBrackets { color: @hi_text; } +.cptGt { color: @theme_text; font-weight: bold; font-size: 120%; } + +/* Use absolute positioning to put all children 100%x100% */ +.cptStack { + position: relative; +} +.cptStack > * { + position: absolute; + top: 0; height: 100%; bottom: 0; left: 0; width: 100%; right: 0; +} + + + + + + + + +/* Layout */ +.cptOutput { + background: @bg; color: @text; + font-family: @fonts; font-size: 90%; + overflow: hidden; + position: absolute; +} + +/* The input area */ +.cptInput { + border-top: 1px solid @border_bg; + background: @input_bg; + background: -moz-linear-gradient(top, @input_bg, @input_bg2); + background: -webkit-gradient(linear, left top, left bottom, from(@input_bg), to(@input_bg2)); + + position: absolute; + height: 25px; right: 0; bottom: 0; left: 0; +} + +.cptPrompt { + width: 40px; padding-left: 5px; + position: absolute; + top: 0; width: 40px; bottom: 0; left: 0; +} + +.cptKbd { + position: absolute; + top: 0; right: 0; bottom: 0; left: 40px; +} + +.cptOutput input.cptCliInput { + color: @hi_text; font-size: 120%; font-family: @fonts; + background: transparent; + outline: none; border: 0; + box-shadow: none; -moz-box-shadow: none; -webkit-box-shadow: none; + padding: 4px 0 0 0; + width: 100%; +} +.cptOutput input.cptCliInput:focus { + outline: none; border: 0; + box-shadow: none; -moz-box-shadow: none; -webkit-box-shadow: none; +} + + + + +.cptRowIn { + display: box; display: -moz-box; display: -webkit-box; + box-orient: horizontal; -moz-box-orient: horizontal; -webkit-box-orient: horizontal; + box-align: center; -moz-box-align: center; -webkit-box-align: center; + color: @text; + background-color: @input_bg; + width: 100%; +} +.cptRowIn > * { padding-left: 2px; padding-right: 2px; } +.cptRowIn > img { cursor: pointer; } +.cptHover { display: none; } +.cptRowIn:hover > .cptHover { display: block; } +.cptRowIn:hover > .cptHover.cptHidden { display: none; } +.cptOutTyped { + box-flex: 1; -moz-box-flex: 1; -webkit-box-flex: 1; + font-weight: bold; color: @hi_text; font-size: 120%; +} +.cptRowOutput { padding-left: 10px; line-height: 1.2em; } +.cptRowOutput strong, +.cptRowOutput b, +.cptRowOutput th, +.cptRowOutput h1, +.cptRowOutput h2, +.cptRowOutput h3 { color: @hi_text; } +.cptRowOutput a { font-weight: bold; color: @link_text; text-decoration: none; } +.cptRowOutput a: hover { text-decoration: underline; cursor: pointer; } +.cptRowOutput input[type=password], +.cptRowOutput input[type=text], +.cptRowOutput textarea { + color: @hi_text; font-size: 120%; + background: transparent; padding: 3px; + border-radius: 5px; -moz-border-radius: 5px; -webkit-border-radius: 5px; +} +.cptRowOutput table, +.cptRowOutput td, +.cptRowOutput th { border: 0; padding: 0 2px; } +.cptRowOutput .right { text-align: right; } diff --git a/plugins/cockpit/lib/ui/plain.js b/plugins/cockpit/lib/ui/plain.js index 445a7864..f6953bf5 100644 --- a/plugins/cockpit/lib/ui/plain.js +++ b/plugins/cockpit/lib/ui/plain.js @@ -42,17 +42,88 @@ var dom = require("pilot/dom").dom; dom.importCssString(editorCss); var CliRequisition = require('cockpit/cli').CliRequisition; +var Hint = require('cockpit/cli').Hint; var keyutil = require('pilot/keyboard/keyutil'); +var plainRow = require("text!cockpit/ui/plainRow.html"); +var Templater = require("pilot/domtemplate").Templater; +var canon = require("pilot/canon"); +var Status = require('pilot/types').Status; + +/** + * On startup we need to: + * 1. Add 3 sets of elements to the DOM for: + * - command line output + * - input hints + * - completion + * 2. Attach a set of events so the command line works + */ exports.startup = function(data, reason) { + var doc = document; + var win = doc.defaultView; + + var cli = new CliRequisition(); + // TODO: we should have a better way to specify command lines??? - this.input = document.getElementById('cockpit'); - if (!this.input) { + var input = doc.getElementById('cockpit'); + if (!input) { console.log('No element with an id of cockpit. Bailing on plain cli'); return; } - var cli = new CliRequisition(); + var templates = doc.createElement('dic'); + templates.innerHTML = plainRow; + var row = templates.firstChild; + + var completer = doc.createElement('div'); + completer.className = 'cptCompletion VALID'; + input.parentNode.insertBefore(completer, input); + + var hinter = doc.createElement('div'); + hinter.className = 'cptHints'; + input.parentNode.insertBefore(hinter, input); + + var output = doc.createElement('div'); + output.className = 'cptOutput'; + input.parentNode.insertBefore(output, input); + + function resizer() { + var style = win.getComputedStyle(input, null); + + var top = parseInt(style.getPropertyValue('top'), 10); + var height = parseInt(style.getPropertyValue('height'), 10); + var left = parseInt(style.getPropertyValue('left'), 10); + var width = parseInt(style.getPropertyValue('width'), 10); + + completer.style.top = top + 'px'; + completer.style.height = height + 'px'; + completer.style.left = left + 'px'; + completer.style.width = width + 'px'; + + hinter.style.bottom = (win.innerHeight - top) + 'px'; + hinter.style.left = (left + 30) + 'px'; + + output.style.bottom = (win.innerHeight - top) + 'px'; + output.style.left = left + 'px'; + output.style.width = width + 'px'; + } + + win.addEventListener('resize', resizer.bind(this), true); + resizer(); + + // TODO: be less brutal in how we update this + output.innerHTML = ''; + canon.addEventListener('output', function(ev) { + ev.requests.forEach(function(request) { + request.outputs.forEach(function(out) { + if (typeof out === 'string') { + output.appendChild(doc.createTextNode(out)); + } else { + output.appendChild(out); + } + }, this); + }, this); + }.bind(this)); /* // All this does is to kill TABs normal use. I wonder if we can train @@ -69,7 +140,11 @@ exports.startup = function(data, reason) { }.bind(this)); */ - this.input.addEventListener('keyup', function(ev) { + var NO_HINT = new Hint(Status.VALID, '', 0, 0); + var hints = []; + var worst; + + input.addEventListener('keyup', function(ev) { /* var handled = keyboardManager.processKeyEvent(ev, this, { isCommandLine: true, isKeyUp: true @@ -78,16 +153,88 @@ exports.startup = function(data, reason) { if (ev.keyCode === keyutil.KeyHelper.KEY.RETURN) { cli.exec(); - this.input.value = ''; + input.value = ''; } else { cli.update({ - typed: this.input.value, + typed: input.value, cursor: { - start: this.input.selectionStart, - end: this.input.selectionEnd + start: input.selectionStart, + end: input.selectionEnd } }); - console.log(JSON.stringify(cli.getHints())); + + completer.classList.remove(Status.VALID.toString()); + completer.classList.remove(Status.INCOMPLETE.toString()); + completer.classList.remove(Status.INVALID.toString()); + + // TODO: borked implementation? + // dom.removeCssClass(completer, Status.VALID.toString()); + // dom.removeCssClass(completer, Status.INCOMPLETE.toString()); + // dom.removeCssClass(completer, Status.INVALID.toString()); + + hints = cli.getHints(); + + // Create a marked up version of the input + var highlightedInput = ''; + if (input.value.length > 0) { + // 'scores' is an array which tells us what chars are errors + // Initialize with everything VALID + var scores = input.value.split('').map(function(char) { + return Status.VALID; + }); + // For all chars in all hints, check and upgrade the score + hints.forEach(function(hint) { + for (var i = hint.start; i <= hint.end; i++) { + if (hint.status > scores[i]) { + scores[i] = hint.status; + } + } + }, this); + // Create markup + var i = 0; + var lastStatus = -1; + while (true) { + if (lastStatus !== scores[i]) { + highlightedInput += ''; + lastStatus = scores[i]; + } + highlightedInput += input.value[i]; + i++; + if (i === input.value.length) { + highlightedInput += ''; + break; + } + if (lastStatus !== scores[i]) { + highlightedInput += ''; + } + } + } + + worst = Hint.worst(hints) || NO_HINT; + var message = worst.message; + if (worst.predictions && worst.predictions.length > 0) { + message += ' [ '; + worst.predictions.forEach(function(prediction) { + if (prediction.name) { + message += prediction.name + ' | '; + } + else { + message += prediction + ' | '; + } + }, this); + message = message.replace(/\| $/, ']'); + + var completion = worst.predictions[0]; + completion = completion.name ? completion.name : completion; + completer.innerHTML = highlightedInput + '  -> ' + completion; + } + else { + completer.innerHTML = highlightedInput; + } + hinter.innerHTML = message; + + completer.classList.add(worst.status.toString()); + // dom.addCssClass(input, worst.status.toString()); } // return handled; diff --git a/plugins/cockpit/lib/ui/plainRow.html b/plugins/cockpit/lib/ui/plainRow.html new file mode 100644 index 00000000..ca40e36c --- /dev/null +++ b/plugins/cockpit/lib/ui/plainRow.html @@ -0,0 +1,47 @@ + + + +
+ +
{ } >
+ +
+
+ +
+ +
+
+
+ + + + + +
+ +
+ + +
>
+
+ + +
+ Hide command output + Show command output + Remove this command from the history + +
+ + +
+
+ +
+
diff --git a/plugins/pilot/lib/canon.js b/plugins/pilot/lib/canon.js index aa308f49..dc303de4 100644 --- a/plugins/pilot/lib/canon.js +++ b/plugins/pilot/lib/canon.js @@ -113,7 +113,7 @@ var commands = {}; * decoration to be done. * TODO: Are we sure that in the future there will be no such decoration? */ -exports.addCommand = function(command) { +function addCommand(command) { if (!command.name) { throw new Error('All registered commands must have a name'); } @@ -138,7 +138,7 @@ exports.addCommand = function(command) { commands[command.name] = command; }; -exports.removeCommand = function(command) { +function removeCommand(command) { if (typeof command === 'string') { delete commands[command]; } @@ -147,11 +147,11 @@ exports.removeCommand = function(command) { } }; -exports.getCommand = function(name) { +function getCommand(name) { return commands[name]; }; -exports.getCommandNames = function() { +function getCommandNames() { return Object.keys(commands); }; @@ -160,7 +160,7 @@ exports.getCommandNames = function() { * everything it needs to about the command params * @param command Either a command, or the name of one */ -exports.exec = function(command, args) { +function exec(command, args) { if (typeof command === 'string') { command = commands[command]; } @@ -176,8 +176,15 @@ exports.exec = function(command, args) { return true; }; +exports.removeCommand = removeCommand; +exports.addCommand = addCommand; +exports.getCommand = getCommand; +exports.getCommandNames = getCommandNames; +exports.exec = exec; + + /** - * We publish a 'addedRequestOutput' event whenever new command begins output + * We publish a 'output' event whenever new command begins output * TODO: make this more obvious */ oop.implement(exports, EventEmitter); @@ -202,7 +209,7 @@ oop.implement(exports, EventEmitter); /** * The array of requests that wish to announce their presence */ -exports.requests = []; +var requests = []; /** * How many requests do we store? @@ -212,16 +219,17 @@ var maxRequestLength = 100; /** * Called by Request instances when some output (or a cell to async() happens) */ -exports.addRequestOutput = function(request) { - exports.requests.push(request); +function addRequestOutput(request) { + requests.push(request); // This could probably be optimized with some maths, but 99.99% of the // time we will only be off by one, and I'm feeling lazy. - while (exports.requests.length > maxRequestLength) { - exports.requests.shiftObject(); + while (requests.length > maxRequestLength) { + requests.shiftObject(); } - exports._dispatchEvent('addedRequestOutput', { request: request }); + exports._dispatchEvent('output', { requests: requests }); }; +exports.addRequestOutput = addRequestOutput; /** * To create an invocation, you need to do something like this (all the ctor diff --git a/plugins/pilot/lib/commands/settings.js b/plugins/pilot/lib/commands/settings.js index f2b6100b..efcbc5ea 100644 --- a/plugins/pilot/lib/commands/settings.js +++ b/plugins/pilot/lib/commands/settings.js @@ -61,24 +61,24 @@ var setCommandSpec = { // 'set' by itself lists all the settings var settingsList = env.settings._list(); html = ''; - // first sort the settingsList based on the key + // first sort the settingsList based on the name settingsList.sort(function(a, b) { - if (a.key < b.key) { + if (a.name < b.name) { return -1; - } else if (a.key == b.key) { + } else if (a.name == b.name) { return 0; } else { return 1; } }); - var url = 'https://wiki.mozilla.org/Labs/Skywriter/Settings#' + - setting.key; settingsList.forEach(function(setting) { + var url = 'https://wiki.mozilla.org/Labs/Skywriter/Settings#' + + setting.name; html += '' + - setting.key + + setting.name + ' = ' + setting.value + '
'; diff --git a/plugins/pilot/lib/domtemplate.js b/plugins/pilot/lib/domtemplate.js new file mode 100644 index 00000000..809f974a --- /dev/null +++ b/plugins/pilot/lib/domtemplate.js @@ -0,0 +1,406 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is DomTemplate. + * + * The Initial Developer of the Original Code is Mozilla. + * Portions created by the Initial Developer are Copyright (C) 2009 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Joe Walker (jwalker@mozilla.com) (original author) + * + * Alternatively, the contents of this file may be used under the terms of + * either the GNU General Public License Version 2 or later (the "GPL"), or + * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), + * in which case the provisions of the GPL or the LGPL are applicable instead + * of those above. If you wish to allow use of your version of this file only + * under the terms of either the GPL or the LGPL, and not to allow others to + * use your version of this file under the terms of the MPL, indicate your + * decision by deleting the provisions above and replace them with the notice + * and other provisions required by the GPL or the LGPL. If you do not delete + * the provisions above, a recipient may use your version of this file under + * the terms of any one of the MPL, the GPL or the LGPL. + * + * ***** END LICENSE BLOCK ***** */ + +define(function(require, exports, module) { + + +// WARNING: do not 'use_strict' without reading the notes in envEval; + +/** + * A templater that allows one to quickly template DOM nodes. + */ +function Templater() { + this.scope = []; +}; + +/** + * Recursive function to walk the tree processing the attributes as it goes. + * @param node the node to process. If you pass a string in instead of a DOM + * element, it is assumed to be an id for use with document.getElementById() + * @param data the data to use for node processing. + */ +Templater.prototype.processNode = function(node, data) { + if (typeof node === 'string') { + node = document.getElementById(node); + } + if (data === null || data === undefined) { + data = {}; + } + this.scope.push(node.nodeName + (node.id ? '#' + node.id : '')); + try { + // Process attributes + if (node.attributes && node.attributes.length) { + // We need to handle 'foreach' and 'if' first because they might stop + // some types of processing from happening, and foreach must come first + // because it defines new data on which 'if' might depend. + if (node.hasAttribute('foreach')) { + this.processForEach(node, data); + return; + } + if (node.hasAttribute('if')) { + if (!this.processIf(node, data)) { + return; + } + } + // Only make the node available once we know it's not going away + data.__element = node; + // It's good to clean up the attributes when we've processed them, + // but if we do it straight away, we mess up the array index + var attrs = Array.prototype.slice.call(node.attributes); + for (var i = 0; i < attrs.length; i++) { + var value = attrs[i].value; + var name = attrs[i].name; + this.scope.push(name); + try { + if (name === 'save') { + // Save attributes are a setter using the node + value = this.stripBraces(value); + this.property(value, data, node); + node.removeAttribute('save'); + } else if (name.substring(0, 2) === 'on') { + // Event registration relies on property doing a bind + value = this.stripBraces(value); + var func = this.property(value, data); + if (typeof func !== 'function') { + this.handleError('Expected ' + value + + ' to resolve to a function, but got ' + typeof func); + } + node.removeAttribute(name); + var capture = node.hasAttribute('capture' + name.substring(2)); + node.addEventListener(name.substring(2), func, capture); + if (capture) { + node.removeAttribute('capture' + name.substring(2)); + } + } else { + // Replace references in all other attributes + var self = this; + var newValue = value.replace(/\$\{[^}]*\}/g, function(path) { + return self.envEval(path.slice(2, -1), data, value); + }); + // Remove '_' prefix of attribute names so the DOM won't try + // to use them before we've processed the template + if (name.charAt(0) === '_') { + node.removeAttribute(name); + node.setAttribute(name.substring(1), newValue); + } else if (value !== newValue) { + attrs[i].value = newValue; + } + } + } finally { + this.scope.pop(); + } + } + } + + // Loop through our children calling processNode. First clone them, so the + // set of nodes that we visit will be unaffected by additions or removals. + var childNodes = Array.prototype.slice.call(node.childNodes); + for (var j = 0; j < childNodes.length; j++) { + this.processNode(childNodes[j], data); + } + + if (node.nodeType === Node.TEXT_NODE) { + this.processTextNode(node, data); + } + } finally { + this.scope.pop(); + } +}; + +/** + * Handle + * @param node An element with an 'if' attribute + * @param data The data to use with envEval + * @returns true if processing should continue, false otherwise + */ +Templater.prototype.processIf = function(node, data) { + this.scope.push('if'); + try { + var originalValue = node.getAttribute('if'); + var value = this.stripBraces(originalValue); + var recurse = true; + try { + var reply = this.envEval(value, data, originalValue); + recurse = !!reply; + } catch (ex) { + this.handleError('Error with \'' + value + '\'', ex); + recurse = false; + } + if (!recurse) { + node.parentNode.removeChild(node); + } + node.removeAttribute('if'); + return recurse; + } finally { + this.scope.pop(); + } +}; + +/** + * Handle and the special case of + * + * @param node An element with a 'foreach' attribute + * @param data The data to use with envEval + */ +Templater.prototype.processForEach = function(node, data) { + this.scope.push('foreach'); + try { + var originalValue = node.getAttribute('foreach'); + var value = originalValue; + + var paramName = 'param'; + if (value.charAt(0) === '$') { + // No custom loop variable name. Use the default: 'param' + value = this.stripBraces(value); + } else { + // Extract the loop variable name from 'NAME in ${ARRAY}' + var nameArr = value.split(' in '); + paramName = nameArr[0].trim(); + value = this.stripBraces(nameArr[1].trim()); + } + node.removeAttribute('foreach'); + try { + var self = this; + // Process a single iteration of a loop + var processSingle = function(member, clone, ref) { + ref.parentNode.insertBefore(clone, ref); + data[paramName] = member; + self.processNode(clone, data); + delete data[paramName]; + }; + + // processSingle is no good for nodes where we want to work on + // the childNodes rather than the node itself + var processAll = function(scope, member) { + self.scope.push(scope); + try { + if (node.nodeName === 'LOOP') { + for (var i = 0; i < node.childNodes.length; i++) { + var clone = node.childNodes[i].cloneNode(true); + processSingle(member, clone, node); + } + } else { + var clone = node.cloneNode(true); + clone.removeAttribute('foreach'); + processSingle(member, clone, node); + } + } finally { + self.scope.pop(); + } + }; + + var reply = this.envEval(value, data, originalValue); + if (Array.isArray(reply)) { + reply.forEach(function(data, i) { + processAll('' + i, data); + }, this); + } else { + for (var param in reply) { + if (reply.hasOwnProperty(param)) { + processAll(param, param); + } + } + } + node.parentNode.removeChild(node); + } catch (ex) { + this.handleError('Error with \'' + value + '\'', ex); + } + } finally { + this.scope.pop(); + } +}; + +/** + * Take a text node and replace it with another text node with the ${...} + * sections parsed out. We replace the node by altering node.parentNode but + * we could probably use a DOM Text API to achieve the same thing. + * @param node The Text node to work on + * @param data The data to use in calls to envEval + */ +Templater.prototype.processTextNode = function(node, data) { + // Replace references in other attributes + var value = node.data; + // We can't use the string.replace() with function trick (see generic + // attribute processing in processNode()) because we need to support + // functions that return DOM nodes, so we can't have the conversion to a + // string. + // Instead we process the string as an array of parts. In order to split + // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002' + // We can then split using \uF001 or \uF002 to get an array of strings + // where scripts are prefixed with $. + // \uF001 and \uF002 are just unicode chars reserved for private use. + value = value.replace(/\$\{([^}]*)\}/g, '\uF001$$$1\uF002'); + var parts = value.split(/\uF001|\uF002/); + if (parts.length > 1) { + parts.forEach(function(part) { + if (part === null || part === undefined || part === '') { + return; + } + if (part.charAt(0) === '$') { + part = this.envEval(part.slice(1), data, node.data); + } + // It looks like this was done a few lines above but see envEval + if (part === null) { + part = "null"; + } + if (part === undefined) { + part = "undefined"; + } + // if (isDOMElement(part)) { ... } + if (typeof part.cloneNode !== 'function') { + part = node.ownerDocument.createTextNode(part.toString()); + } + node.parentNode.insertBefore(part, node); + }, this); + node.parentNode.removeChild(node); + } +}; + +/** + * Warn of string does not begin '${' and end '}' + * @param str the string to check. + * @return The string stripped of ${ and }, or untouched if it does not match + */ +Templater.prototype.stripBraces = function(str) { + if (!str.match(/\$\{.*\}/g)) { + this.handleError('Expected ' + str + ' to match ${...}'); + return str; + } + return str.slice(2, -1); +}; + +/** + * Combined getter and setter that works with a path through some data set. + * For example: + *
    + *
  • property('a.b', { a: { b: 99 }}); // returns 99 + *
  • property('a', { a: { b: 99 }}); // returns { b: 99 } + *
  • property('a', { a: { b: 99 }}, 42); // returns 99 and alters the + * input data to be { a: { b: 42 }} + *
+ * @param path An array of strings indicating the path through the data, or + * a string to be cut into an array using split('.') + * @param data An object to look in for the path argument + * @param newValue (optional) If defined, this value will replace the + * original value for the data at the path specified. + * @return The value pointed to by path before any + * newValue is applied. + */ +Templater.prototype.property = function(path, data, newValue) { + this.scope.push(path); + try { + if (typeof path === 'string') { + path = path.split('.'); + } + var value = data[path[0]]; + if (path.length === 1) { + if (newValue !== undefined) { + data[path[0]] = newValue; + } + if (typeof value === 'function') { + return function() { + return value.apply(data, arguments); + }; + } + return value; + } + if (!value) { + this.handleError('Can\'t find path=' + path); + return null; + } + return this.property(path.slice(1), value, newValue); + } finally { + this.scope.pop(); + } +}; + +/** + * Like eval, but that creates a context of the variables in env in + * which the script is evaluated. + * WARNING: This script uses 'with' which is generally regarded to be evil. + * The alternative is to create a Function at runtime that takes X parameters + * according to the X keys in the env object, and then call that function using + * the values in the env object. This is likely to be slow, but workable. + * @param script The string to be evaluated. + * @param env The environment in which to eval the script. + * @param context Optional debugging string in case of failure + * @return The return value of the script, or the error message if the script + * execution failed. + */ +Templater.prototype.envEval = function(script, env, context) { + with (env) { + try { + this.scope.push(context); + return eval(script); + } catch (ex) { + this.handleError('Template error evaluating \'' + script + '\'', ex); + return script; + } finally { + this.scope.pop(); + } + } +}; + +/** + * A generic way of reporting errors, for easy overloading in different + * environments. + * @param message the error message to report. + * @param ex optional associated exception. + */ +Templater.prototype.handleError = function(message, ex) { + this.logError(message); + this.logError('In: ' + this.scope.join(' > ')); + if (ex) { + this.logError(ex); + } +}; + + +/** + * A generic way of reporting errors, for easy overloading in different + * environments. + * @param message the error message to report. + */ +Templater.prototype.logError = function(message) { + console.log(message); +}; + +if (this.exports) { + exports.Templater = Templater; +} + + +}); diff --git a/plugins/pilot/lib/settings.js b/plugins/pilot/lib/settings.js index fbe8b2ab..2024d62f 100644 --- a/plugins/pilot/lib/settings.js +++ b/plugins/pilot/lib/settings.js @@ -204,7 +204,7 @@ Settings.prototype = { resetAll: function() { this.getSettingNames().forEach(function(key) { this.resetValue(key); - }.bind(this)); + }, this); }, /** @@ -215,9 +215,9 @@ Settings.prototype = { this.getSettingNames().forEach(function(setting) { reply.push({ 'key': setting, - 'value': this.get(setting) + 'value': this.getSetting(setting).get() }); - }.bind(this)); + }, this); return reply; }, diff --git a/plugins/pilot/lib/types.js b/plugins/pilot/lib/types.js index 0452b0a6..abc1c4d7 100644 --- a/plugins/pilot/lib/types.js +++ b/plugins/pilot/lib/types.js @@ -48,16 +48,21 @@ var Status = { * valid. There are a number of failure states, so the best way to check * for failure is (x !== Status.VALID) */ - VALID: 'VALID', + VALID: { + toString: function() { return 'VALID'; }, + valueOf: function() { return 0; } + }, /** - * The conversion process did not work like Status.INVALID, however it was - * noted that the string provided to 'parse()' could be VALID by the - * addition of more characters, so the typing may not be actually incorrect - * yet, just unfinished. + * A conversion process failed, however it was noted that the string + * provided to 'parse()' could be VALID by the addition of more characters, + * so the typing may not be actually incorrect yet, just unfinished. * @see Status.INVALID */ - INCOMPLETE: 'INCOMPLETE', + INCOMPLETE: { + toString: function() { return 'INCOMPLETE'; }, + valueOf: function() { return 1; } + }, /** * The conversion process did not work, the value should be null and a @@ -65,7 +70,10 @@ var Status = { * values may be available. * @see Status.INCOMPLETE */ - INVALID: 'INVALID', + INVALID: { + toString: function() { return 'INVALID'; }, + valueOf: function() { return 2; } + }, /** * A combined status is the worser of the provided statuses diff --git a/plugins/pilot/lib/types/basic.js b/plugins/pilot/lib/types/basic.js index 8e7e5ebc..f6e2adc5 100644 --- a/plugins/pilot/lib/types/basic.js +++ b/plugins/pilot/lib/types/basic.js @@ -119,8 +119,8 @@ SelectionType.prototype.stringify = function(value) { return value; }; -SelectionType.prototype.parse = function(value) { - if (typeof value != 'string') { +SelectionType.prototype.parse = function(str) { + if (typeof str != 'string') { throw new Error('non-string passed to parse()'); } if (!this.data) { @@ -128,32 +128,40 @@ SelectionType.prototype.parse = function(value) { } var data = (typeof(this.data) === "function") ? this.data() : this.data; - var match = false; + var match; var completions = []; data.forEach(function(option) { - if (value == option) { - match = true; + if (str == option) { + match = this.fromString(option); } - else if (option.indexOf(value) === 0) { - completions.push(option); + else if (option.indexOf(str) === 0) { + completions.push(this.fromString(option)); } - }); + }, this); if (match) { - return new Conversion(value); + return new Conversion(match); } else { - var status = completions.length > 0 ? Status.INCOMPLETE : Status.INVALID; - - // TODO: better error message - include options? - // TODO: better completions - we're just using the extensions - return new Conversion(null, - status, - 'Can\'t convert \'' + value + '\' to a selection.', - completions); + if (completions.length > 0) { + return new Conversion(null, + Status.INCOMPLETE, + 'Several possibilities for \'' + str + '\'', + completions); + } + else { + return new Conversion(null, + Status.INVALID, + 'Can\'t use \'' + str + '\'.', + completions); + } } }; +SelectionType.prototype.fromString = function(str) { + return str; +}; + SelectionType.prototype.name = 'selection'; /** @@ -170,17 +178,8 @@ var bool = new SelectionType({ stringify: function(value) { return '' + value; }, - parse: function(value) { - var conversion = SelectionType.prototype.parse(value); - - if (conversion.value === 'true') { - conversion.value = true; - } - if (conversion.value === 'false') { - conversion.value = false; - } - - return conversion; + fromString: function(str) { + return str === 'true' ? true : false; } }); diff --git a/plugins/pilot/lib/types/command.js b/plugins/pilot/lib/types/command.js index b53fc19d..f29c8bc2 100644 --- a/plugins/pilot/lib/types/command.js +++ b/plugins/pilot/lib/types/command.js @@ -54,15 +54,8 @@ var command = new SelectionType({ stringify: function(command) { return command.name; }, - parse: function(value) { - var conversion = SelectionType.prototype.parse.call(this, value); - if (conversion.value) { - conversion.value = canon.getCommand(conversion.value); - } - else { - conversion.message = 'Several possibilities for \'' + value + '\''; - } - return conversion; + fromString: function(str) { + return canon.getCommand(str); } }); diff --git a/plugins/pilot/lib/types/settings.js b/plugins/pilot/lib/types/settings.js index 587a1df7..4b64d069 100644 --- a/plugins/pilot/lib/types/settings.js +++ b/plugins/pilot/lib/types/settings.js @@ -63,17 +63,9 @@ var setting = new SelectionType({ lastSetting = setting; return setting.name; }, - parse: function(text) { - lastSetting = text; - - var conversion = SelectionType.prototype.parse.call(this, text); - if (conversion.value) { - conversion.value = settings.getSetting(conversion.value); - } - else { - conversion.message = 'Several possibilities for \'' + text + '\''; - } - return conversion; + fromString: function(str) { + lastSetting = settings.getSetting(str); + return lastSetting; } }); @@ -84,7 +76,7 @@ var setting = new SelectionType({ var settingValue = new DeferredType({ name: 'settingValue', defer: function() { - return env.settings.getSetting(lastSetting).type; + return lastSetting.type; } });