commands/Command.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * @module bajaux/commands/Command
 */
define([ 'lex!', 
         'jquery',
         'Promise',
         'bajaux/events' ], function (
          lex, 
          $,
          Promise,
          events) {
            
  'use strict';
  
  var idCounter = 0,
  
      COMMAND_CHANGE_EVENT = events.command.CHANGE_EVENT,
      COMMAND_INVOKE_EVENT = events.command.INVOKE_EVENT,
      COMMAND_FAIL_EVENT = events.command.FAIL_EVENT,

      noop = function () {},
      defaultToggleFunc = function () {
        // By default, a toggle command just toggles its state.
        this.toggle();
      };
        
  function parseAccelerator(acc) {
    var a = null,
        codes, c, i, res;
    
    if (acc) {
      if (acc && typeof acc === "object") {
        a = acc;
      } else {
        a = {
          keyCode: acc.toLowerCase()
        };
      }
      
      // If we have a String then convert the character code.
      if (typeof a.keyCode === "string") {
        codes = a.keyCode.split("+");
        for (i = 0; i < codes.length; ++i) {
          c = codes[i];
          
          if (c.length === 1) {
            // Process keycode
            a.keyCode = c.charCodeAt(0);
          } else {
            // Process modifiers
            if (c === "alt") {
              a.alt = true;
            } else if (c === "shift") {
              a.shift = true;
            } else if (c === "ctrl") {
              a.ctrl = true;
            } else if (c === "meta") {
              a.meta = true;
            } else if (c === "backspace") { // Process other key codes
              a.keyCode = 8;
            } else if (c === "tab") {
              a.keyCode = 9;
            } else if (c === "space") {
              a.keyCode = 32;
            } else if (c === "pageup") {
              a.keyCode = 33;
            } else if (c === "pagedown") {
              a.keyCode = 34;
            } else if (c === "left") {
              a.keyCode = 37;
            } else if (c === "up") {
              a.keyCode = 38;
            } else if (c === "right") {
              a.keyCode = 39;
            } else if (c === "down") {
              a.keyCode = 40;
            } else if (c === "delete") {
              a.keyCode = 46; // 127 in BAccelerator
            } else if (c === "end") {
              a.keyCode = 35;
            } else if (c === "esc") {
              a.keyCode = 27;
            } else if (c === "home") {
              a.keyCode = 36;
            } else if (c === "insert") {
              a.keyCode = 45; // 155 in BAccelerator;
            } else if (c.charAt(0) === "f") {
              // Process function keys
              res = /f([1-9]+)/.exec(c);
              if (res) {
                a.keyCode = 112 + (parseInt(res[1], 10) - 1);
              }
            }
          }
        }
      }
    }
    
    return a;
  }
    
  /**
   * A Command is essentially an asynchronous function with a nicely formatted
   * display name attached.
   * 
   * @class
   * @alias module:bajaux/commands/Command
   * 
   * @param {String|Object} params An Object Literal or the display name for 
   * the Command.
   * 
   * @param {String} params.displayName The display name format
   * for the Command. This format will be used in
   * `toDisplayName`. This can also be the first argument, in which
   * case `func` must be the second.
   * 
   * @param {Function} params.func The function this command will execute. The
   * function can return a promise if it's going to be invoked asynchronously.
   * 
   * @param {String} [params.description] A description of the 
   * Command. This format will be used in `toDescription`.
   * 
   * @param {Boolean} [params.enabled] The enabled state of the Command.
   * Defaults to `true`.
   * 
   * @param {Number} [params.flags] The Command flags. Defaults to
   * `Command.flags.ALL`.
   * 
   * @param {String} [params.icon] The Command Icon. This can also be a
   * String encoding an icon will be created from.
   * 
   * @param {Function} [func] the function to invoke, if using the two-argument
   * constructor
   * 
   * @example
   * new Command("baja.Format compatible display name", function () {
   *   alert("I'm a command!");
   * });
   *
   * new Command("Format compatible display name", function () {
   *   return new Promise(function (resolve, reject) {
   *     setTimeout(function () {
   *       wobble.foo();
   *       resolve;
   *     }, 1000);
   *   });
   * });
   * 
   * new Command({
   *   displayName: "I'll be converted to a Format: %lexicon(baja:january)%",
   *   description: "I'll be converted to a Format too: %lexicon(baja:true)%",
   *   func: function () {
   *     alert("I'm a command!");
   *   }
   * });
   * 
   * new Command({
   *   module: "myModule", // Create a Command that gets its displayName, description                  
   *   lex: "myCommand",   // and icon from a module's lexicon.
   *   func: function () {
   *     alert("I'm a command!");
   *   }
   * });
   */
  var Command = function Command(params, func) {    
    var that = this,
        loadingPromise;
        
    params = params && params.constructor === Object ? params
             : { displayName: params, func: func };
                 
    // Assign variables
    that.$displayName = params.displayName || "";
    that.$description = params.description || "";
    that.$func = params.func || (that.isToggleCommand() ? defaultToggleFunc : noop);
    
    that.$enabled = params.enabled === undefined ? true : params.enabled;
    that.$flags = params.flags === undefined ? Command.flags.ALL : params.flags;
    that.setIcon(params.icon || "");
    that.$id = idCounter++;
    that.$accelerator = parseAccelerator(params.accelerator); 
    that.$jq = params.jq || null;
    
    // If a module and lexicon is specified then resolve the lexicon
    // and get the value. This asynchronously updates the Command. When
    // the Command is updated, an change event will be fired to update
    // any user interfaces.
    var pending = true;
    if (params.module && params.lex) {
      // Always update asynchronously
      loadingPromise = lex.module(params.module)
        .then(function (moduleLex) {
          var res;
          try {
            res = moduleLex.get(params.lex + ".displayName");
            if (res) {
              that.setDisplayNameFormat(res);
            }

            res = moduleLex.get(params.lex + ".description");
            if (res) {
              that.setDescriptionFormat(res);
            }

            res = moduleLex.get(params.lex + ".icon");
            if (res) {
              that.setIcon(res);
            }

            res = moduleLex.get(params.lex + ".accelerator");
            if (res) {
              that.setAccelerator(res);
            }
          } catch (ignore) {
          } finally {
            pending = false;
          }
        });
    } else {
      pending = false;
      loadingPromise = Promise.resolve();
    }
    loadingPromise.isPending = () => pending;
    that.$loading = loadingPromise;
  };
      
  /** 
   * Namespace containing numbers for comparing flag bits. C&P'ed directly
   * from MgrController in workbench module.
   */
  Command.flags = {};
  
  /** Match all flags. */
  Command.flags.ALL = 0xFFFF;
  
  /** Makes the command be available in the main menu. */
  Command.flags.MENU_BAR = 0x0001;
  
  /** Makes the command be available in the main toolbar. */
  Command.flags.TOOL_BAR = 0x0002;

  /** Match no flags */
  Command.flags.NONE = 0x0000;

  /**
   * Return true if the Command is still loading.
   * 
   * @returns {Boolean} true if still loading.
   */
  Command.prototype.isLoading = function isLoading() {
    return this.$loading.isPending();
  };

  /**
   * Return the loading promise for the Command.
   *
   * The returned promise will be resolved once the Command
   * has finished loading.
   * 
   * @returns {Promise} The promise used for loading a Command.
   */
  Command.prototype.loading = function loading() {
    return this.$loading;
  };
    
  /**
   * Return the format display name of the command.
   *
   * @returns {String}
   */
  Command.prototype.getDisplayNameFormat = function getDisplayNameFormat() {
    return this.$displayName;
  };
  
  /**
   * Set the display name format of the command. Triggers a
   * `bajaux:changecommand` event.
   * 
   * @param {String} displayName display name - supports baja Format syntax
   */
  Command.prototype.setDisplayNameFormat = function setDisplayNameFormat(displayName) {
    this.$displayName = displayName;
    this.trigger(COMMAND_CHANGE_EVENT);
  };
    
  /**
   * Access the Command's display name.
   * 
   * In order to access the display name, a promise will be returned
   * that will be resolved once the command has been loaded and
   * the display name has been found.
   *
   * @returns {Promise} Promise to be resolved with the display name
   */   
  Command.prototype.toDisplayName = function toDisplayName() {
    var that = this;
    return that.$loading
      .then(function () { return lex.format(that.$displayName); });
  };
  
  /**
   * Get the unformatted description of the command.
   * 
   * @returns {String}
   */
  Command.prototype.getDescriptionFormat = function getDescriptionFormat() {
    return this.$description;
  };
  
  /**
   * Set the description format of the command. Triggers a
   * `bajaux:changecommand` event.
   *
   * @param {String} description the command description - supports baja Format
   * syntax
   */
  Command.prototype.setDescriptionFormat = function setDescriptionFormat(description) {
    this.$description = description;
    this.trigger(COMMAND_CHANGE_EVENT);
  };
  
  /**
   * Access the Command's description.
   * 
   * In order to access the description, a promise will be returned
   * that will be resolved once the command has been loaded and
   * the description has been found.
   *
   * @returns {Promise} Promise to be resolved with the description
   */   
  Command.prototype.toDescription = function toDescription() {
    var that = this;
    
    return that.$loading
      .then(function () { return lex.format(that.$description); });
  };
  
  /**
   * Return the Command's icon URI
   *
   * @returns {String}
   */
  Command.prototype.getIcon = function getIcon() {
    return this.$icon;
  };
  
  /**
   * Sets the icon for this Command. Triggers a `bajaux:changecommand` event.
   * 
   * @param {String} icon The Command's icon (either a URI or a module:// ORD string)
   */
  Command.prototype.setIcon = function setIcon(icon) {
    this.$icon = icon.replace(/^module:\/\//, "/module/");
    this.trigger(COMMAND_CHANGE_EVENT);
  };
  
  /**
   * Gets this command's enabled status.
   * 
   * @returns {Boolean}
   */
  Command.prototype.isEnabled = function isEnabled() {
    return this.$enabled;
  };
  
  /**
   * Sets this command's enabled status. Triggers a
   * `bajaux:changecommand` event.
   * 
   * @param {Boolean} enabled
   */
  Command.prototype.setEnabled = function setEnabled(enabled) {
    this.$enabled = enabled;
    this.trigger(COMMAND_CHANGE_EVENT);
  };
  
  /**
   * Get this command's flags.
   * 
   * @returns {Number}
   */
  Command.prototype.getFlags = function getFlags() {
    return this.$flags;
  };
  
  /**
   * Set this command's flags.
   * 
   * @param {Number} flags
   */
  Command.prototype.setFlags = function setFlags(flags) {
    this.$flags = flags;
    this.trigger(COMMAND_CHANGE_EVENT);
  };
  
  /**
   * Check to see if this command's flags match any of the bits of the
   * input flags.
   * 
   * @param {Number} flags The flags to check against
   * @returns {Boolean}
   */
  Command.prototype.hasFlags = function hasFlags(flags) {
    return !!(this.$flags & flags);
  };
    
  /**
   * Return the raw function associated with this command.
   * 
   * @returns {Function}
   */
  Command.prototype.getFunction = function getFunction() {
    return this.$func;
  };
  
  /**
   * Set the Command's function handler.
   * 
   * @param {Function} func The new function handler for the command.
   */
  Command.prototype.setFunction = function setFunction(func) {
    this.$func = func;
    this.trigger(COMMAND_CHANGE_EVENT);
  };
    
  /**
   * Invoke the Command. Triggers a `bajaux:invokecommand` or
   * `bajaux:failcommand` event, as appropriate.
   * 
   * Arguments can be passed into `invoke()` that will be passed into the
   * function's Command Handler.
   * 
   * @returns {Promise} A promise object that will be resolved (or rejected)
   * once the Command's function handler has finished invoking.
   */
  Command.prototype.invoke = function invoke() {
    var that = this,
        args = arguments;

    // eslint-disable-next-line promise/avoid-new
    return Promise.try(function () {
      return that.$func.apply(that, args);
    })
      .then(function (result) {
        var args = Array.prototype.slice.call(arguments);
        that.trigger.apply(that, [ COMMAND_INVOKE_EVENT ].concat(args));
        return result;
      }, function (err) {
        var args = Array.prototype.slice.call(arguments);
        that.trigger.apply(that, [ COMMAND_FAIL_EVENT ].concat(args));
        throw err;
      });
  };
  
  /**
   * Always returns true.
   */
  Command.prototype.isCommand = function isCommand() {
    return true;
  };
  
  /**
   * Always returns false.
   */
  Command.prototype.isToggleCommand = function isToggleCommand() {
    return false;
  };
  
  /**
   * Return a unique numerical id for the Command.
   * 
   * This is id unique to every Command object created.
   */
  Command.prototype.getId = function getId() {
    return this.$id;
  };
  
  /**
   * Return the accelerator for the Command or null if 
   * nothing is defined. 
   *
   * @see module:bajaux/commands/Command#setAccelerator
   *
   * @returns {Object} The accelerator or null if nothing is defined.
   */
  Command.prototype.getAccelerator = function getAccelerator() {
    return this.$accelerator;
  };
    
  /**
   * Set the accelerator information for the Command.
   *
   * @param {Object|String|Number|null|undefined} acc The accelerator keyboard information. This can
   * be a keyCode number, a character (i.e. 'a') or an Object that contains the accelerator information.
   * If no accelerator should be used the null/undefined should be specified.
   * @param {String|Number} acc.keyCode The key code of the accelerator. This can be a character
   * code number or a character (i.e. 'a').
   * @param {Boolean} [acc.ctrl] `true` if the control key needs to be pressed.
   * @param {Boolean} [acc.shift] `true` if the shift key needs to be pressed.
   * @param {Boolean} [acc.alt] `true` if the alt key needs to be pressed.
   * @param {Boolean} [acc.meta] `true` if a meta key needs to be pressed.
   *
   * @see module:bajaux/commands/Command#getAccelerator
   */
  Command.prototype.setAccelerator = function setAccelerator(acc) {
    this.$accelerator = parseAccelerator(acc);
    
    // Trigger a change event
    this.trigger(COMMAND_CHANGE_EVENT);
  };
  
  /**
   * Visit this Command with the specified function.
   *
   * @param {Function} func Will be invoked with this
   * Command passed in as an argument.
   */
  Command.prototype.visit = function visit(func) {
    return func(this);
  };
    
  /**
   * Triggers an event from this Command.
   * 
   * @param {String} name
   */
  Command.prototype.trigger = function trigger(name) {
    var that = this,
        passedArgs = Array.prototype.slice.call(arguments, 1),
        args = [ that ].concat(passedArgs);
 
    if (that.$jq) {
      that.$jq.trigger(name, args);
    }

    if (that.$eh) {
      that.$eh.trigger(name, args);
    }
  };
  
  /**
   * If a jQuery DOM argument is specified, this will set the DOM.
   * If not specified then no DOM will be set.
   * This method will always return the jQuery DOM associated with this Command.
   *
   * @param {JQuery} [jqDom] If specified, this will set the jQuery DOM.
   * @returns {JQuery} A jQuery DOM object for firing events on.
   */
  Command.prototype.jq = function jq(jqDom) {
    if (jqDom !== undefined) {
      this.$jq = jqDom;
    }
    return this.$jq || null;
  };

  /**
   * Register a function callback handler for the specified event.
   * 
   * @param  {String} event The event id to register the function for.
   * @param  {Function} handler The event handler to be called when the event is fired.
   */
  Command.prototype.on = function (event, handler) {
    var that = this;
    that.$eh = that.$eh || $("<div></div>");
    that.$eh.on.apply(that.$eh, arguments);
  };

  /**
   * Unregister a function callback handler for the specified event. 
   * 
   * @param  {String} [event] The name of the event to unregister.
   * If name isn't specified, all events for the Command will be unregistered.
   * @param  {Function} [handler] The function to unregister. If 
   * not specified, all handlers for the event will be unregistered.
   */
  Command.prototype.off = function (event, handler) {
    var that = this;
    if (that.$eh) {
      that.$eh.off.apply(that.$eh, arguments);
    }
  };

  /**
   * Attempt to merge this command with another command, and return a new
   * Command that does both tasks. If the two commands are mutually
   * incompatible, return a falsy value.
   * 
   * @param {module:bajaux/commands/Command} cmd
   * @returns {module:bajaux/commands/Command}
   * 
   * @example
   * <caption>
   *   Here is an example to show the basic concept. Commands that simply
   *   add two numbers together can easily be merged together thanks to the
   *   associative property.
   * </caption>
   * 
   * var AddCommand = function AddCommand(inc) {
   *   this.$inc = inc;
   *   Command.call(this, {
   *     displayName: 'Add ' + inc + ' to the given number',
   *     func: function (num) { return num + inc; }
   *   });
   * };
   * AddCommand.prototype = Object.create(Command.prototype);
   * 
   * AddCommand.prototype.merge = function (cmd) {
   *   if (cmd instanceof AddCommand) {
   *     return new AddCommand(this.$inc + cmd.$inc);
   *   }
   * };
   * 
   * var addOneCommand = new AddCommand(1),
   *     addFiveCommand = new AddCommand(5),
   *     addSixCommand = addOneCommand.merge(addFiveCommand);
   * addSixCommand.invoke(10)
   *   .then(function (result) {
   *     console.log('is 16? ', result === 16);
   *   });
   */
  Command.prototype.merge = function (cmd) {
    return null;
  };
  
  return Command;
});