<commit_before>// Copyright 2013-2020, University of Colorado Boulder

/**
 * Scenery-based combo box. Composed of a button and a popup 'list box' of items. ComboBox has no interaction of its
 * own, all interaction is handled by its subcomponents. The list box is displayed when the button is pressed, and
 * dismissed when an item is selected, the user clicks on the button, or the user clicks outside the list. The list
 * can be displayed either above or below the button.
 *
 * The supporting classes are:
 *
 * ComboBoxItem - items provided to ComboBox constructor
 * ComboBoxButton - the button
 * ComboBoxListBox - the list box
 * ComboBoxListItemNode - an item in the list box
 *
 * For info on ComboBox UI design, including a11y, see https://github.com/phetsims/sun/blob/master/doc/ComboBox.md
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */

import BooleanProperty from '../../axon/js/BooleanProperty.js';
import Vector2 from '../../dot/js/Vector2.js';
import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js';
import merge from '../../phet-core/js/merge.js';
import PDOMPeer from '../../scenery/js/accessibility/pdom/PDOMPeer.js';
import Display from '../../scenery/js/display/Display.js';
import Node from '../../scenery/js/nodes/Node.js';
import generalCloseSoundPlayer from '../../tambo/js/shared-sound-players/generalCloseSoundPlayer.js';
import generalOpenSoundPlayer from '../../tambo/js/shared-sound-players/generalOpenSoundPlayer.js';
import EventType from '../../tandem/js/EventType.js';
import Tandem from '../../tandem/js/Tandem.js';
import IOType from '../../tandem/js/types/IOType.js';
import ComboBoxButton from './ComboBoxButton.js';
import ComboBoxListBox from './ComboBoxListBox.js';
import sun from './sun.js';

// const
const LIST_POSITION_VALUES = [ 'above', 'below' ]; // where the list pops up relative to the button
const ALIGN_VALUES = [ 'left', 'right', 'center' ]; // alignment of item on button and in list

// The definition for how ComboBox sets its accessibleName and helpText in the PDOM. Forward it onto its button. See
// ComboBox.md for further style guide and documentation on the pattern.
const ACCESSIBLE_NAME_BEHAVIOR = ( node, options, accessibleName, otherNodeCallbacks ) => {
  otherNodeCallbacks.push( () => {
    node.button.accessibleName = accessibleName;
  } );
  return options;
};
const HELP_TEXT_BEHAVIOR = ( node, options, helpText, otherNodeCallbacks ) => {
  otherNodeCallbacks.push( () => {
    node.button.helpText = helpText;
  } );
  return options;
};

class ComboBox extends Node {

  /**
   * @param {ComboBoxItem[]} items
   * @param {Property} property
   * @param {Node} listParent node that will be used as the list's parent, use this to ensure that the list is in front of everything else
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  constructor( items, property, listParent, options ) {

    // See https://github.com/phetsims/sun/issues/542
    assert && assert( listParent.maxWidth === null,
      'ComboBox is responsible for scaling listBox. Setting maxWidth for listParent may result in buggy behavior.' );

    options = merge( {

      align: 'left', // see ALIGN_VALUES
      listPosition: 'below', // see LIST_POSITION_VALUES
      labelNode: null, // {Node|null} optional label, placed to the left of the combo box
      labelXSpacing: 10, // horizontal space between label and combo box
      enabledProperty: null, // {BooleanProperty|null} default will be provided if null
      disabledOpacity: 0.5, // {number} opacity used to make the control look disabled, 0-1
      cornerRadius: 4, // applied to button, listBox, and item highlights
      highlightFill: 'rgb( 245, 245, 245 )', // {Color|string} highlight behind items in the list

      // Margins around the edges of the button and listbox when highlight is invisible.
      // Highlight margins around the items in the list are set to 1/2 of these values.
      // These values must be > 0.
      xMargin: 12,
      yMargin: 8,

      // button
      buttonFill: 'white', // {Color|string}
      buttonStroke: 'black', // {Color|string}
      buttonLineWidth: 1,
      buttonTouchAreaXDilation: 0,
      buttonTouchAreaYDilation: 0,
      buttonMouseAreaXDilation: 0,
      buttonMouseAreaYDilation: 0,

      // list
      listFill: 'white', // {Color|string}
      listStroke: 'black', // {Color|string}
      listLineWidth: 1,

      // {Playable} - Sound generators for when combo box is opened and for when it is closed with no change (closing
      // *with* a change is handled elsewhere).
      openedSoundPlayer: generalOpenSoundPlayer,
      closedNoChangeSoundPlayer: generalCloseSoundPlayer,

      // pdom
      tagName: 'div', // must have accessible content to support behavior functions
      accessibleNameBehavior: ACCESSIBLE_NAME_BEHAVIOR,
      helpTextBehavior: HELP_TEXT_BEHAVIOR,

      // phet-io
      tandem: Tandem.REQUIRED,
      phetioType: ComboBox.ComboBoxIO,
      phetioEventType: EventType.USER,
      visiblePropertyOptions: { phetioFeatured: true }
    }, options );

    // validate option values
    assert && assert( options.xMargin > 0 && options.yMargin > 0,
      'margins must be > 0, xMargin=' + options.xMargin + ', yMargin=' + options.yMargin );
    assert && assert( options.disabledOpacity > 0 && options.disabledOpacity < 1,
      'invalid disabledOpacity: ' + options.disabledOpacity );
    assert && assert( _.includes( LIST_POSITION_VALUES, options.listPosition ),
      'invalid listPosition: ' + options.listPosition );
    assert && assert( _.includes( ALIGN_VALUES, options.align ),
      'invalid align: ' + options.align );

    super();

    this.items = items; // @private
    this.listPosition = options.listPosition; // @private

    // optional label
    if ( options.labelNode !== null ) {
      this.addChild( options.labelNode );
    }

    // @private button that shows the current selection
    this.button = new ComboBoxButton( property, items, {
      align: options.align,
      arrowDirection: ( options.listPosition === 'below' ) ? 'down' : 'up',
      cornerRadius: options.cornerRadius,
      xMargin: options.xMargin,
      yMargin: options.yMargin,
      baseColor: options.buttonFill,
      stroke: options.buttonStroke,
      lineWidth: options.buttonLineWidth,
      touchAreaXDilation: options.buttonTouchAreaXDilation,
      touchAreaYDilation: options.buttonTouchAreaYDilation,
      mouseAreaXDilation: options.buttonMouseAreaXDilation,
      mouseAreaYDilation: options.buttonMouseAreaYDilation,

      // pdom - accessibleName and helpText are set via behavior functions on the ComboBox

      // phet-io
      tandem: options.tandem.createTandem( 'button' )
    } );
    this.addChild( this.button );

    // put optional label to left of button
    if ( options.labelNode ) {
      this.button.left = options.labelNode.right + options.labelXSpacing;
      this.button.centerY = options.labelNode.centerY;
    }

    // @private the popup list box
    this.listBox = new ComboBoxListBox( property, items,
      this.hideListBox.bind( this ), // callback to hide the list box
      this.button.focus.bind( this.button ), // callback to transfer focus to button
      options.tandem.createTandem( 'listBox' ), {
        align: options.align,
        highlightFill: options.highlightFill,
        xMargin: options.xMargin,
        yMargin: options.yMargin,
        cornerRadius: options.cornerRadius,
        fill: options.listFill,
        stroke: options.listStroke,
        lineWidth: options.listLineWidth,
        visible: false,

        // sound generation
        openedSoundPlayer: options.openedSoundPlayer,
        closedNoChangeSoundPlayer: options.closedNoChangeSoundPlayer,

        // pdom
        // the list box is aria-labelledby its own label sibling
        ariaLabelledbyAssociations: [ {
          otherNode: this.button,
          otherElementName: PDOMPeer.LABEL_SIBLING,
          thisElementName: PDOMPeer.PRIMARY_SIBLING
        } ]
      } );
    listParent.addChild( this.listBox );
    this.listParent = listParent; // @private

    // The listBox is not a child Node of ComboBox and, as a result, listen to opacity of the ComboBox and keep
    // the listBox in sync with them. See https://github.com/phetsims/sun/issues/587
    this.opacityProperty.link( opacity => { this.listBox.opacityProperty.value = opacity; } );

    this.mutate( options );

    // Clicking on the button toggles visibility of the list box
    this.button.addListener( () => {
      this.listBox.visibleProperty.value = !this.listBox.visibleProperty.value;
    } );

    //TODO sun#462 integrate this with above button listener, to eliminate order dependency
    // Handle button clicks, for a11y
    this.button.addInputListener( {
      click: () => {
        if ( this.listBox.visible ) {
          this.listBox.focus();
        }
      }
    } );

    // @private the display that clickToDismissListener is added to, because the scene may change, see sun#14
    this.display = null;

    // @private Clicking anywhere other than the button or list box will hide the list box.
    this.clickToDismissListener = {
      down: event => {

        // Ignore if we click over the button, since the button will handle hiding the list.
        if ( !( event.trail.containsNode( this.button ) || event.trail.containsNode( this.listBox ) ) ) {
          this.hideListBox();
        }
      }
    };

    // @private - (PDOM) when focus leaves the ComboBoxListBox, it should be closed. This could happen from keyboard
    // or from other screen reader controls (like VoiceOver gestures)
    this.dismissWithFocusListener = focus => {
      if ( focus && !focus.trail.containsNode( this.listBox ) ) {
        this.hideListBox();
      }
    };
    Display.focusProperty.link( this.dismissWithFocusListener );

    // So we know whether we can dispose of the enabledProperty and its tandem
    const ownsEnabledProperty = !options.enabledProperty;

    // @public Provide a default if not specified
    this.enabledProperty = options.enabledProperty || new BooleanProperty( true, {
      tandem: options.tandem.createTandem( 'enabledProperty' ),
      phetioFeatured: true
    } );

    // enable/disable the combo box
    const enabledObserver = enabled => {
      this.pickable = enabled;
      this.opacity = enabled ? 1.0 : options.disabledOpacity;
      this.button.setAccessibleAttribute( 'aria-disabled', !enabled );
    };
    this.enabledProperty.link( enabledObserver );

    this.listBox.localBoundsProperty.lazyLink( () => this.moveListBox() );

    this.listBox.visibleProperty.link( ( visible, wasVisible ) => {
      if ( visible ) {

        // show the list box
        this.scaleListBox();
        this.listBox.moveToFront();
        this.moveListBox();

        // manage clickToDismissListener
        assert && assert( !this.display, 'unexpected display' );
        this.display = this.getUniqueTrail().rootNode().getRootedDisplays()[ 0 ];
        this.display.addInputListener( this.clickToDismissListener );
      }
      else {

        // manage clickToDismissListener
        if ( this.display && this.display.hasInputListener( this.clickToDismissListener ) ) {
          this.display.removeInputListener( this.clickToDismissListener );
          this.display = null;
        }
      }
    } );

    // @private for use via PhET-iO, see https://github.com/phetsims/sun/issues/451
    // This is not generally controlled by the user, so it is not reset when the Reset All button is pressed.
    this.displayOnlyProperty = new BooleanProperty( false, {
      tandem: options.tandem.createTandem( 'displayOnlyProperty' ),
      phetioFeatured: true,
      phetioDocumentation: 'disables interaction with the ComboBox and ' +
                           'makes it appear like a display that shows the current selection'
    } );
    this.displayOnlyProperty.link( displayOnly => {
      this.hideListBox();
      this.button.setDisplayOnly( displayOnly );
      this.pickable = !displayOnly;
    } );

    this.addLinkedElement( property, {
      tandem: options.tandem.createTandem( 'property' )
    } );

    // @private called by dispose
    this.disposeComboBox = () => {

      if ( this.display && this.display.hasInputListener( this.clickToDismissListener ) ) {
        this.display.removeInputListener( this.clickToDismissListener );
      }

      if ( ownsEnabledProperty ) {
        this.enabledProperty.dispose();
      }
      else {
        this.enabledProperty.unlink( enabledObserver );
      }

      Display.focusProperty.unlink( this.dismissWithFocusListener );

      // dispose of subcomponents
      this.listBox.dispose();
      this.button.dispose();
    };

    // support for binder documentation, stripped out in builds and only runs when ?binder is specified
    assert && phet.chipper.queryParameters.binder && InstanceRegistry.registerDataURL( 'sun', 'ComboBox', this );
  }

  // @public - Provide dispose() on the prototype for ease of subclassing.
  dispose() {
    this.disposeComboBox();
    Node.prototype.dispose.call( this );
  }

  // @public
  setEnabled( enabled ) { this.enabledProperty.value = enabled; }

  set enabled( value ) { this.setEnabled( value ); }

  // @public
  getEnabled() { return this.enabledProperty.value; }

  get enabled() { return this.getEnabled(); }

  /**
   * Shows the list box.
   * @public
   */
  showListBox() {
    this.listBox.visibleProperty.value = true;
  }

  /**
   * Hides the list box.
   * @public
   */
  hideListBox() {
    this.listBox.visibleProperty.value = false;
  }

  /**
   * Because the button and list box have different parents (and therefore different coordinate frames)
   * they may be scaled differently. This method scales the list box so that items on the button and in
   * the list appear to be the same size.
   * @private
   */
  scaleListBox() {

    // To support an empty list box due to PhET-iO customization, see https://github.com/phetsims/sun/issues/606
    if ( !this.listBox.localBounds.isEmpty() ) {
      const buttonScale = this.button.localToGlobalBounds( this.button.localBounds ).width / this.button.localBounds.width;
      const listBoxScale = this.listBox.localToGlobalBounds( this.listBox.localBounds ).width / this.listBox.localBounds.width;
      this.listBox.scale( buttonScale / listBoxScale );
    }
  }

  /**
   * Handles the coordinate transform required to make the list box pop up near the button.
   * @private
   */
  moveListBox() {
    if ( this.listPosition === 'above' ) {
      const pButtonGlobal = this.localToGlobalPoint( new Vector2( this.button.left, this.button.top ) );
      const pButtonLocal = this.listParent.globalToLocalPoint( pButtonGlobal );
      this.listBox.left = pButtonLocal.x;
      this.listBox.bottom = pButtonLocal.y;
    }
    else {
      const pButtonGlobal = this.localToGlobalPoint( new Vector2( this.button.left, this.button.bottom ) );
      const pButtonLocal = this.listParent.globalToLocalPoint( pButtonGlobal );
      this.listBox.left = pButtonLocal.x;
      this.listBox.top = pButtonLocal.y;
    }
  }
}

ComboBox.ComboBoxIO = new IOType( 'ComboBoxIO', {
  valueType: ComboBox,
  documentation: 'A combo box is composed of a push button and a listbox. The listbox contains items that represent ' +
                 'choices. Pressing the button pops up the listbox. Selecting from an item in the listbox sets the ' +
                 'value of an associated Property. The button shows the item that is currently selected.',
  supertype: Node.NodeIO,
  events: [ 'listBoxShown', 'listBoxHidden' ]
} );

sun.register( 'ComboBox', ComboBox );
export default ComboBox;<commit_msg>fix dispose, call super.dispose<commit_after>// Copyright 2013-2020, University of Colorado Boulder

/**
 * Scenery-based combo box. Composed of a button and a popup 'list box' of items. ComboBox has no interaction of its
 * own, all interaction is handled by its subcomponents. The list box is displayed when the button is pressed, and
 * dismissed when an item is selected, the user clicks on the button, or the user clicks outside the list. The list
 * can be displayed either above or below the button.
 *
 * The supporting classes are:
 *
 * ComboBoxItem - items provided to ComboBox constructor
 * ComboBoxButton - the button
 * ComboBoxListBox - the list box
 * ComboBoxListItemNode - an item in the list box
 *
 * For info on ComboBox UI design, including a11y, see https://github.com/phetsims/sun/blob/master/doc/ComboBox.md
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */

import BooleanProperty from '../../axon/js/BooleanProperty.js';
import Vector2 from '../../dot/js/Vector2.js';
import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js';
import merge from '../../phet-core/js/merge.js';
import PDOMPeer from '../../scenery/js/accessibility/pdom/PDOMPeer.js';
import Display from '../../scenery/js/display/Display.js';
import Node from '../../scenery/js/nodes/Node.js';
import generalCloseSoundPlayer from '../../tambo/js/shared-sound-players/generalCloseSoundPlayer.js';
import generalOpenSoundPlayer from '../../tambo/js/shared-sound-players/generalOpenSoundPlayer.js';
import EventType from '../../tandem/js/EventType.js';
import Tandem from '../../tandem/js/Tandem.js';
import IOType from '../../tandem/js/types/IOType.js';
import ComboBoxButton from './ComboBoxButton.js';
import ComboBoxListBox from './ComboBoxListBox.js';
import sun from './sun.js';

// const
const LIST_POSITION_VALUES = [ 'above', 'below' ]; // where the list pops up relative to the button
const ALIGN_VALUES = [ 'left', 'right', 'center' ]; // alignment of item on button and in list

// The definition for how ComboBox sets its accessibleName and helpText in the PDOM. Forward it onto its button. See
// ComboBox.md for further style guide and documentation on the pattern.
const ACCESSIBLE_NAME_BEHAVIOR = ( node, options, accessibleName, otherNodeCallbacks ) => {
  otherNodeCallbacks.push( () => {
    node.button.accessibleName = accessibleName;
  } );
  return options;
};
const HELP_TEXT_BEHAVIOR = ( node, options, helpText, otherNodeCallbacks ) => {
  otherNodeCallbacks.push( () => {
    node.button.helpText = helpText;
  } );
  return options;
};

class ComboBox extends Node {

  /**
   * @param {ComboBoxItem[]} items
   * @param {Property} property
   * @param {Node} listParent node that will be used as the list's parent, use this to ensure that the list is in front of everything else
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  constructor( items, property, listParent, options ) {

    // See https://github.com/phetsims/sun/issues/542
    assert && assert( listParent.maxWidth === null,
      'ComboBox is responsible for scaling listBox. Setting maxWidth for listParent may result in buggy behavior.' );

    options = merge( {

      align: 'left', // see ALIGN_VALUES
      listPosition: 'below', // see LIST_POSITION_VALUES
      labelNode: null, // {Node|null} optional label, placed to the left of the combo box
      labelXSpacing: 10, // horizontal space between label and combo box
      enabledProperty: null, // {BooleanProperty|null} default will be provided if null
      disabledOpacity: 0.5, // {number} opacity used to make the control look disabled, 0-1
      cornerRadius: 4, // applied to button, listBox, and item highlights
      highlightFill: 'rgb( 245, 245, 245 )', // {Color|string} highlight behind items in the list

      // Margins around the edges of the button and listbox when highlight is invisible.
      // Highlight margins around the items in the list are set to 1/2 of these values.
      // These values must be > 0.
      xMargin: 12,
      yMargin: 8,

      // button
      buttonFill: 'white', // {Color|string}
      buttonStroke: 'black', // {Color|string}
      buttonLineWidth: 1,
      buttonTouchAreaXDilation: 0,
      buttonTouchAreaYDilation: 0,
      buttonMouseAreaXDilation: 0,
      buttonMouseAreaYDilation: 0,

      // list
      listFill: 'white', // {Color|string}
      listStroke: 'black', // {Color|string}
      listLineWidth: 1,

      // {Playable} - Sound generators for when combo box is opened and for when it is closed with no change (closing
      // *with* a change is handled elsewhere).
      openedSoundPlayer: generalOpenSoundPlayer,
      closedNoChangeSoundPlayer: generalCloseSoundPlayer,

      // pdom
      tagName: 'div', // must have accessible content to support behavior functions
      accessibleNameBehavior: ACCESSIBLE_NAME_BEHAVIOR,
      helpTextBehavior: HELP_TEXT_BEHAVIOR,

      // phet-io
      tandem: Tandem.REQUIRED,
      phetioType: ComboBox.ComboBoxIO,
      phetioEventType: EventType.USER,
      visiblePropertyOptions: { phetioFeatured: true }
    }, options );

    // validate option values
    assert && assert( options.xMargin > 0 && options.yMargin > 0,
      'margins must be > 0, xMargin=' + options.xMargin + ', yMargin=' + options.yMargin );
    assert && assert( options.disabledOpacity > 0 && options.disabledOpacity < 1,
      'invalid disabledOpacity: ' + options.disabledOpacity );
    assert && assert( _.includes( LIST_POSITION_VALUES, options.listPosition ),
      'invalid listPosition: ' + options.listPosition );
    assert && assert( _.includes( ALIGN_VALUES, options.align ),
      'invalid align: ' + options.align );

    super();

    this.items = items; // @private
    this.listPosition = options.listPosition; // @private

    // optional label
    if ( options.labelNode !== null ) {
      this.addChild( options.labelNode );
    }

    // @private button that shows the current selection
    this.button = new ComboBoxButton( property, items, {
      align: options.align,
      arrowDirection: ( options.listPosition === 'below' ) ? 'down' : 'up',
      cornerRadius: options.cornerRadius,
      xMargin: options.xMargin,
      yMargin: options.yMargin,
      baseColor: options.buttonFill,
      stroke: options.buttonStroke,
      lineWidth: options.buttonLineWidth,
      touchAreaXDilation: options.buttonTouchAreaXDilation,
      touchAreaYDilation: options.buttonTouchAreaYDilation,
      mouseAreaXDilation: options.buttonMouseAreaXDilation,
      mouseAreaYDilation: options.buttonMouseAreaYDilation,

      // pdom - accessibleName and helpText are set via behavior functions on the ComboBox

      // phet-io
      tandem: options.tandem.createTandem( 'button' )
    } );
    this.addChild( this.button );

    // put optional label to left of button
    if ( options.labelNode ) {
      this.button.left = options.labelNode.right + options.labelXSpacing;
      this.button.centerY = options.labelNode.centerY;
    }

    // @private the popup list box
    this.listBox = new ComboBoxListBox( property, items,
      this.hideListBox.bind( this ), // callback to hide the list box
      this.button.focus.bind( this.button ), // callback to transfer focus to button
      options.tandem.createTandem( 'listBox' ), {
        align: options.align,
        highlightFill: options.highlightFill,
        xMargin: options.xMargin,
        yMargin: options.yMargin,
        cornerRadius: options.cornerRadius,
        fill: options.listFill,
        stroke: options.listStroke,
        lineWidth: options.listLineWidth,
        visible: false,

        // sound generation
        openedSoundPlayer: options.openedSoundPlayer,
        closedNoChangeSoundPlayer: options.closedNoChangeSoundPlayer,

        // pdom
        // the list box is aria-labelledby its own label sibling
        ariaLabelledbyAssociations: [ {
          otherNode: this.button,
          otherElementName: PDOMPeer.LABEL_SIBLING,
          thisElementName: PDOMPeer.PRIMARY_SIBLING
        } ]
      } );
    listParent.addChild( this.listBox );
    this.listParent = listParent; // @private

    // The listBox is not a child Node of ComboBox and, as a result, listen to opacity of the ComboBox and keep
    // the listBox in sync with them. See https://github.com/phetsims/sun/issues/587
    this.opacityProperty.link( opacity => { this.listBox.opacityProperty.value = opacity; } );

    this.mutate( options );

    // Clicking on the button toggles visibility of the list box
    this.button.addListener( () => {
      this.listBox.visibleProperty.value = !this.listBox.visibleProperty.value;
    } );

    //TODO sun#462 integrate this with above button listener, to eliminate order dependency
    // Handle button clicks, for a11y
    this.button.addInputListener( {
      click: () => {
        if ( this.listBox.visible ) {
          this.listBox.focus();
        }
      }
    } );

    // @private the display that clickToDismissListener is added to, because the scene may change, see sun#14
    this.display = null;

    // @private Clicking anywhere other than the button or list box will hide the list box.
    this.clickToDismissListener = {
      down: event => {

        // Ignore if we click over the button, since the button will handle hiding the list.
        if ( !( event.trail.containsNode( this.button ) || event.trail.containsNode( this.listBox ) ) ) {
          this.hideListBox();
        }
      }
    };

    // @private - (PDOM) when focus leaves the ComboBoxListBox, it should be closed. This could happen from keyboard
    // or from other screen reader controls (like VoiceOver gestures)
    this.dismissWithFocusListener = focus => {
      if ( focus && !focus.trail.containsNode( this.listBox ) ) {
        this.hideListBox();
      }
    };
    Display.focusProperty.link( this.dismissWithFocusListener );

    // So we know whether we can dispose of the enabledProperty and its tandem
    const ownsEnabledProperty = !options.enabledProperty;

    // @public Provide a default if not specified
    this.enabledProperty = options.enabledProperty || new BooleanProperty( true, {
      tandem: options.tandem.createTandem( 'enabledProperty' ),
      phetioFeatured: true
    } );

    // enable/disable the combo box
    const enabledObserver = enabled => {
      this.pickable = enabled;
      this.opacity = enabled ? 1.0 : options.disabledOpacity;
      this.button.setAccessibleAttribute( 'aria-disabled', !enabled );
    };
    this.enabledProperty.link( enabledObserver );

    this.listBox.localBoundsProperty.lazyLink( () => this.moveListBox() );

    this.listBox.visibleProperty.link( ( visible, wasVisible ) => {
      if ( visible ) {

        // show the list box
        this.scaleListBox();
        this.listBox.moveToFront();
        this.moveListBox();

        // manage clickToDismissListener
        assert && assert( !this.display, 'unexpected display' );
        this.display = this.getUniqueTrail().rootNode().getRootedDisplays()[ 0 ];
        this.display.addInputListener( this.clickToDismissListener );
      }
      else {

        // manage clickToDismissListener
        if ( this.display && this.display.hasInputListener( this.clickToDismissListener ) ) {
          this.display.removeInputListener( this.clickToDismissListener );
          this.display = null;
        }
      }
    } );

    // @private for use via PhET-iO, see https://github.com/phetsims/sun/issues/451
    // This is not generally controlled by the user, so it is not reset when the Reset All button is pressed.
    this.displayOnlyProperty = new BooleanProperty( false, {
      tandem: options.tandem.createTandem( 'displayOnlyProperty' ),
      phetioFeatured: true,
      phetioDocumentation: 'disables interaction with the ComboBox and ' +
                           'makes it appear like a display that shows the current selection'
    } );
    this.displayOnlyProperty.link( displayOnly => {
      this.hideListBox();
      this.button.setDisplayOnly( displayOnly );
      this.pickable = !displayOnly;
    } );

    this.addLinkedElement( property, {
      tandem: options.tandem.createTandem( 'property' )
    } );

    // @private called by dispose
    this.disposeComboBox = () => {

      if ( this.display && this.display.hasInputListener( this.clickToDismissListener ) ) {
        this.display.removeInputListener( this.clickToDismissListener );
      }

      if ( ownsEnabledProperty ) {
        this.enabledProperty.dispose();
      }
      else {
        this.enabledProperty.unlink( enabledObserver );
      }

      Display.focusProperty.unlink( this.dismissWithFocusListener );

      // dispose of subcomponents
      this.listBox.dispose();
      this.button.dispose();
    };

    // support for binder documentation, stripped out in builds and only runs when ?binder is specified
    assert && phet.chipper.queryParameters.binder && InstanceRegistry.registerDataURL( 'sun', 'ComboBox', this );
  }

  // @public - Provide dispose() on the prototype for ease of subclassing.
  dispose() {
    this.disposeComboBox();
    super.dispose();
  }

  // @public
  setEnabled( enabled ) { this.enabledProperty.value = enabled; }

  set enabled( value ) { this.setEnabled( value ); }

  // @public
  getEnabled() { return this.enabledProperty.value; }

  get enabled() { return this.getEnabled(); }

  /**
   * Shows the list box.
   * @public
   */
  showListBox() {
    this.listBox.visibleProperty.value = true;
  }

  /**
   * Hides the list box.
   * @public
   */
  hideListBox() {
    this.listBox.visibleProperty.value = false;
  }

  /**
   * Because the button and list box have different parents (and therefore different coordinate frames)
   * they may be scaled differently. This method scales the list box so that items on the button and in
   * the list appear to be the same size.
   * @private
   */
  scaleListBox() {

    // To support an empty list box due to PhET-iO customization, see https://github.com/phetsims/sun/issues/606
    if ( !this.listBox.localBounds.isEmpty() ) {
      const buttonScale = this.button.localToGlobalBounds( this.button.localBounds ).width / this.button.localBounds.width;
      const listBoxScale = this.listBox.localToGlobalBounds( this.listBox.localBounds ).width / this.listBox.localBounds.width;
      this.listBox.scale( buttonScale / listBoxScale );
    }
  }

  /**
   * Handles the coordinate transform required to make the list box pop up near the button.
   * @private
   */
  moveListBox() {
    if ( this.listPosition === 'above' ) {
      const pButtonGlobal = this.localToGlobalPoint( new Vector2( this.button.left, this.button.top ) );
      const pButtonLocal = this.listParent.globalToLocalPoint( pButtonGlobal );
      this.listBox.left = pButtonLocal.x;
      this.listBox.bottom = pButtonLocal.y;
    }
    else {
      const pButtonGlobal = this.localToGlobalPoint( new Vector2( this.button.left, this.button.bottom ) );
      const pButtonLocal = this.listParent.globalToLocalPoint( pButtonGlobal );
      this.listBox.left = pButtonLocal.x;
      this.listBox.top = pButtonLocal.y;
    }
  }
}

ComboBox.ComboBoxIO = new IOType( 'ComboBoxIO', {
  valueType: ComboBox,
  documentation: 'A combo box is composed of a push button and a listbox. The listbox contains items that represent ' +
                 'choices. Pressing the button pops up the listbox. Selecting from an item in the listbox sets the ' +
                 'value of an associated Property. The button shows the item that is currently selected.',
  supertype: Node.NodeIO,
  events: [ 'listBoxShown', 'listBoxHidden' ]
} );

sun.register( 'ComboBox', ComboBox );
export default ComboBox;<|endoftext|>


<commit_before>// Copyright 2013-2016, University of Colorado Boulder

/**
 * Scenery-based combo box. Composed of a button and a list of items.
 * The list of items is displayed when the button is pressed, and dismissed an item is selected
 * or the user clicks outside the list.  The list can be displayed either above or below the button.
 *
 * An item in the combo box has 2 properties: {Node} node, {*} value.
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */
define( function( require ) {
  'use strict';

  // modules
  var inherit = require( 'PHET_CORE/inherit' );
  var Line = require( 'SCENERY/nodes/Line' );
  var Node = require( 'SCENERY/nodes/Node' );
  var Property = require( 'AXON/Property' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );
  var Shape = require( 'KITE/Shape' );
  var sun = require( 'SUN/sun' );
  var Vector2 = require( 'DOT/Vector2' );
  var Tandem = require( 'TANDEM/Tandem' );
  var TandemPath = require( 'TANDEM/scenery/nodes/TandemPath' );

  // phet-io modules
  var TComboBox = require( 'ifphetio!PHET_IO/types/sun/TComboBox' );
  var TNode = require( 'ifphetio!PHET_IO/types/scenery/nodes/TNode' );

  /**
   * @param {*[]} items - see ComboBox.createItem.  The item types should be homogeneous.
   * @param {Property} property
   * @param {Node} listParent node that will be used as the list's parent, use this to ensuring that the list is in front of everything else
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  function ComboBox( items, property, listParent, options ) {

    var self = this;

    options = _.extend( {

      labelNode: null, // optional label, placed to the left of the combo box
      labelXSpacing: 10, // horizontal space between label and combo box
      enabledProperty: new Property( true ),
      disabledOpacity: 0.5, // {number} opacity used to make the control look disabled

      // button
      buttonFill: 'white',
      buttonStroke: 'black',
      buttonLineWidth: 1,
      buttonCornerRadius: 8,
      buttonXMargin: 10,
      buttonYMargin: 4,

      // list
      listPosition: 'below', // where the list is positioned relative to the button, either 'below' or 'above'
      listYMargin: 4,
      listFill: 'white',
      listStroke: 'black',
      listLineWidth: 1,
      listCornerRadius: 5,

      // items
      itemXMargin: 6,
      itemYMargin: 6,
      itemHighlightFill: 'rgb(245,245,245)',
      itemHighlightStroke: null,
      itemHighlightLineWidth: 1,

      // tandem
      tandem: null
    }, options );

    // validate option values
    Tandem.validateOptions( options ); // The tandem is required when brand==='phet-io'
    assert && assert( options.disabledOpacity > 0 && options.disabledOpacity < 1, 'invalid disabledOpacity: ' + options.disabledOpacity );

    Node.call( self );

    this.enabledProperty = options.enabledProperty; // @public

    // optional label
    if ( options.labelNode !== null ) {
      self.addChild( options.labelNode );
    }

    // determine uniform dimensions for button and list items
    var itemWidth = 0;
    var itemHeight = 0;
    for ( var i = 0; i < items.length; i++ ) {
      var item = items[ i ];
      if ( item.node.width > itemWidth ) { itemWidth = item.node.width; }
      if ( item.node.height > itemHeight ) { itemHeight = item.node.height; }
    }
    itemWidth += ( 2 * options.itemXMargin );
    itemHeight += ( 2 * options.itemYMargin );

    // button, will be set to correct value when property observer is registered
    var buttonNode = new ButtonNode( new ItemNode( items[ 0 ], itemWidth, itemHeight, options.itemXMargin ), options );
    self.addChild( buttonNode );

    // list
    var listWidth = itemWidth + ( 2 * options.buttonXMargin );
    var listHeight = ( items.length * itemHeight ) + ( 2 * options.listYMargin );
    var listNode = new Rectangle( 0, 0, listWidth, listHeight, options.listCornerRadius, options.listCornerRadius,
      { fill: options.listFill, stroke: options.listStroke, lineWidth: options.listLineWidth, visible: false } );
    listParent.addChild( listNode );

    //TODO move these to ItemNode
    // how to highlight an item in the list
    var highlightItem = function( itemNode ) {
      itemNode.fill = options.itemHighlightFill;
      itemNode.stroke = options.itemHighlightStroke;
    };
    var unhighlightItem = function( itemNode ) {
      itemNode.fill = null;
      itemNode.stroke = null;
    };

    // listener that we'll attach to each item in the list
    var itemListener = {
      enter: function( event ) {
        highlightItem( event.currentTarget );
      },
      exit: function( event ) {
        unhighlightItem( event.currentTarget );
      },
      down: function( event ) {
        event.abort(); // prevent click-to-dismiss on the list
      },
      up: function( event ) {

        self.trigger1( 'startedCallbacksForItemFired', event.currentTarget.item.value );

        unhighlightItem( event.currentTarget );
        listNode.visible = false; // close the list, do this before changing property value, in case it's expensive
        self.getUniqueTrail().rootNode().removeInputListener( clickToDismissListener ); // remove the click-to-dismiss listener
        event.abort(); // prevent nodes (eg, controls) behind the list from receiving the event
        property.value = event.currentTarget.item.value; // set the property

        self.trigger0( 'endedCallbacksForItemFired' );
      }
    };

    // populate list with items
    for ( var j = 0; j < items.length; j++ ) {

      var itemNodeOptions = items[ j ].options;

      // Create the list item node itself
      var itemNode = new ItemNode( items[ j ], itemWidth, itemHeight, options.itemXMargin, itemNodeOptions );

      // add item to list
      listNode.addChild( itemNode );
      itemNode.left = options.buttonXMargin;
      itemNode.top = options.listYMargin + ( j * itemHeight );

      // item interactivity
      itemNode.cursor = 'pointer';
      itemNode.addInputListener( itemListener );
    }

    //TODO handle scale and rotation
    // Handles the coordinate transform required to make the list pop up near the button.
    var moveList = function() {
      var pButtonGlobal;
      var pButtonLocal;
      if ( options.listPosition === 'above' ) {
        pButtonGlobal = self.localToGlobalPoint( new Vector2( buttonNode.left, buttonNode.top ) );
        pButtonLocal = listParent.globalToLocalPoint( pButtonGlobal );
        listNode.left = pButtonLocal.x;
        listNode.bottom = pButtonLocal.y;
      }
      else {
        pButtonGlobal = self.localToGlobalPoint( new Vector2( buttonNode.left, buttonNode.bottom ) );
        pButtonLocal = listParent.globalToLocalPoint( pButtonGlobal );
        listNode.left = pButtonLocal.x;
        listNode.top = pButtonLocal.y;
      }
    };

    /**
     * Because clickToDismissListener is added to the scene, it receives the 'down' event that
     * buttonNode received to register the listener. This is because scenery propagates events
     * up the event trail, and the scene is further up the trail than the button.  This flag
     * is used to ignore the first 'down' event, which is the one that the button received.
     * If we don't do this, then we never see the list because it is immediately popped down.
     * This behavior is may change, and is being discussed in scenery#58.
     */
    var enableClickToDismissListener;
    var sceneNode; // store the node that clickToDismissListener is added to, because the scene may change, see sun#14

    // listener for 'click outside to dismiss'
    var clickToDismissListener = {
      down: function() {
        if ( enableClickToDismissListener ) {

          self.trigger0( 'startedCallbacksForComboBoxDismissed' );

          sceneNode.removeInputListener( clickToDismissListener );
          listNode.visible = false;

          self.trigger0( 'endedCallbacksForComboBoxDismissed' );
        }
        else {
          enableClickToDismissListener = true;
        }
      }
    };

    // button interactivity
    buttonNode.cursor = 'pointer';
    buttonNode.addInputListener(
      {
        down: function() {
          if ( !listNode.visible ) {
            self.trigger0( 'startedCallbacksForComboBoxPopupShown' );

            moveList();
            listNode.moveToFront();
            listNode.visible = true;
            enableClickToDismissListener = false;
            sceneNode = self.getUniqueTrail().rootNode();
            sceneNode.addInputListener( clickToDismissListener );

            self.trigger0( 'endedCallbacksForComboBoxPopupShown' );
          }
        }
      } );

    // layout
    if ( options.labelNode ) {
      buttonNode.left = options.labelNode.right + options.labelXSpacing;
      buttonNode.centerY = options.labelNode.centerY;
    }

    // when property changes, update button
    var propertyObserver = function( value ) {
      // TODO brute force search, better way?
      var item = null;
      for ( var i = 0; i < items.length; i++ ) {
        if ( items[ i ].value === value ) {
          item = items[ i ];
        }
      }
      assert && assert( item !== null );
      buttonNode.setItemNode( new ItemNode( item, itemWidth, itemHeight, options.itemXMargin ) );
    };
    property.link( propertyObserver );

    this.mutate( options );

    // enable/disable the combo box
    var enabledObserver = function( enabled ) {
      self.pickable = enabled;
      self.opacity = enabled ? 1.0 : options.disabledOpacity;
    };
    this.enabledProperty.link( enabledObserver );

    // @private called by dispose
    this.disposeComboBox = function() {
      self.enabledProperty.unlink( enabledObserver );
      options.tandem && options.tandem.removeInstance( this );
      property.unlink( propertyObserver );
    };

    options.tandem && options.tandem.addInstance( this, TComboBox( property.elementType ) );
  }

  sun.register( 'ComboBox', ComboBox );

  inherit( Node, ComboBox, {

    // @public - Provide dispose() on the prototype for ease of subclassing.
    dispose: function() {
      this.disposeComboBox();
    },

    // @public
    setEnabled: function( enabled ) { this.enabledProperty.value = enabled; },
    set enabled( value ) { this.setEnabled( value ); },

    // @public
    getEnabled: function() { return this.enabledProperty.value; },
    get enabled() { return this.getEnabled(); }
  } );

  /**
   * Creates a combo box item.
   * This exists primarily to document the structure of an item.
   * @param {Node} node
   * @param {*} value
   * @param {Object} [options]
   * @returns {{node:Node, value:*}}
   * @public
   */
  ComboBox.createItem = function( node, value, options ) {
    return { node: node, value: value, options: options };
  };

  /**
   * The button that is clicked to show the list of items.
   * @param {Node} itemNode
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  function ButtonNode( itemNode, options ) {
    Node.call( this );

    // up or down arrow
    var arrow = new TandemPath( null, {
      fill: 'black',
      tandem: options.tandem && options.tandem.createTandem( 'arrow' )
    } );
    var arrowWidth = 0.5 * itemNode.height;
    var arrowHeight = arrowWidth * Math.sqrt( 3 ) / 2; // height of equilateral triangle
    if ( options.listPosition === 'above' ) {
      arrow.shape = new Shape().moveTo( 0, arrowHeight ).lineTo( arrowWidth / 2, 0 ).lineTo( arrowWidth, arrowHeight ).close(); // up arrow
    }
    else {
      arrow.shape = new Shape().moveTo( 0, 0 ).lineTo( arrowWidth, 0 ).lineTo( arrowWidth / 2, arrowHeight ).close(); // down arrow
    }

    // button background
    var width = itemNode.width + ( 4 * options.buttonXMargin ) + arrow.width;
    var height = itemNode.height + ( 2 * options.buttonYMargin );
    var background = new Rectangle( 0, 0, width, height, options.buttonCornerRadius, options.buttonCornerRadius,
      { fill: options.buttonFill, stroke: options.buttonStroke, lineWidth: options.buttonLineWidth } );

    // vertical separator to left of arrow
    var separator = new Line( 0, 0, 0, height, { stroke: 'black', lineWidth: options.buttonLineWidth } );
    options.tandem && options.tandem.createTandem( 'separator' ).addInstance( separator, TNode );

    // itemNode's parent
    var itemNodeParent = new Node();

    // rendering order
    this.addChild( background );
    this.addChild( arrow );
    this.addChild( separator );
    this.addChild( itemNodeParent );

    // @private
    this.setItemNode = function( itemNode ) {
      itemNodeParent.removeAllChildren();
      itemNodeParent.addChild( itemNode );
      itemNode.left = options.buttonXMargin;
      itemNode.top = options.buttonYMargin;
    };
    this.setItemNode( itemNode );

    // layout
    separator.left = itemNode.right + options.buttonXMargin;
    separator.top = background.top;
    arrow.left = separator.right + options.buttonXMargin;
    arrow.centerY = background.centerY;
  }

  sun.register( 'ComboBox.ButtonNode', ButtonNode );

  inherit( Node, ButtonNode );

  /**
   * A wrapper around the combo box item, adds margins, etc.
   * @param {*} item - see ComboBox.createItem
   * @param {number} width
   * @param {number} height
   * @param {number} xMargin
   * @param {Object} [options]
   * @constructor
   * @private
   */
  function ItemNode( item, width, height, xMargin, options ) {
    Rectangle.call( this, 0, 0, width, height );
    this.item = item;
    this.addChild( item.node );
    item.node.pickable = false; // hits will occur on the rectangle
    item.node.x = xMargin;
    item.node.centerY = height / 2;
  }

  sun.register( 'ComboBox.ItemNode', ItemNode );

  inherit( Rectangle, ItemNode );

  return ComboBox;
} );
<commit_msg>Updated to default tandem #190<commit_after>// Copyright 2013-2016, University of Colorado Boulder

/**
 * Scenery-based combo box. Composed of a button and a list of items.
 * The list of items is displayed when the button is pressed, and dismissed an item is selected
 * or the user clicks outside the list.  The list can be displayed either above or below the button.
 *
 * An item in the combo box has 2 properties: {Node} node, {*} value.
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */
define( function( require ) {
  'use strict';

  // modules
  var inherit = require( 'PHET_CORE/inherit' );
  var Line = require( 'SCENERY/nodes/Line' );
  var Node = require( 'SCENERY/nodes/Node' );
  var Property = require( 'AXON/Property' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );
  var Shape = require( 'KITE/Shape' );
  var sun = require( 'SUN/sun' );
  var Vector2 = require( 'DOT/Vector2' );
  var Tandem = require( 'TANDEM/Tandem' );
  var TandemPath = require( 'TANDEM/scenery/nodes/TandemPath' );
  var TandemRectangle = require( 'TANDEM/scenery/nodes/TandemRectangle' );

  // phet-io modules
  var TComboBox = require( 'ifphetio!PHET_IO/types/sun/TComboBox' );
  var TNode = require( 'ifphetio!PHET_IO/types/scenery/nodes/TNode' );

  /**
   * @param {*[]} items - see ComboBox.createItem
   * @param {Property} property
   * @param {Node} listParent node that will be used as the list's parent, use this to ensuring that the list is in front of everything else
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  function ComboBox( items, property, listParent, options ) {

    var self = this;

    options = _.extend( {

      labelNode: null, // optional label, placed to the left of the combo box
      labelXSpacing: 10, // horizontal space between label and combo box
      enabledProperty: new Property( true ),
      disabledOpacity: 0.5, // {number} opacity used to make the control look disabled

      // button
      buttonFill: 'white',
      buttonStroke: 'black',
      buttonLineWidth: 1,
      buttonCornerRadius: 8,
      buttonXMargin: 10,
      buttonYMargin: 4,

      // list
      listPosition: 'below', // where the list is positioned relative to the button, either 'below' or 'above'
      listYMargin: 4,
      listFill: 'white',
      listStroke: 'black',
      listLineWidth: 1,
      listCornerRadius: 5,

      // items
      itemXMargin: 6,
      itemYMargin: 6,
      itemHighlightFill: 'rgb(245,245,245)',
      itemHighlightStroke: null,
      itemHighlightLineWidth: 1,

      // tandem
      tandem: Tandem.createDefaultTandem( 'comboBox' )
    }, options );

    // validate option values
    Tandem.validateOptions( options ); // The tandem is required when brand==='phet-io'
    assert && assert( options.disabledOpacity > 0 && options.disabledOpacity < 1, 'invalid disabledOpacity: ' + options.disabledOpacity );

    Node.call( self );

    this.enabledProperty = options.enabledProperty; // @public

    // optional label
    if ( options.labelNode !== null ) {
      self.addChild( options.labelNode );
    }

    // determine uniform dimensions for button and list items
    var itemWidth = 0;
    var itemHeight = 0;
    for ( var i = 0; i < items.length; i++ ) {
      var item = items[ i ];
      if ( item.node.width > itemWidth ) { itemWidth = item.node.width; }
      if ( item.node.height > itemHeight ) { itemHeight = item.node.height; }
    }
    itemWidth += ( 2 * options.itemXMargin );
    itemHeight += ( 2 * options.itemYMargin );

    // button, will be set to correct value when property observer is registered
    var buttonNode = new ButtonNode( new ItemNode( items[ 0 ], itemWidth, itemHeight, options.itemXMargin, {
      tandem: options.tandem.createTandem( 'buttonNode' ).createTandem( 'itemNode' )
    } ), options );
    self.addChild( buttonNode );

    // list
    var listWidth = itemWidth + ( 2 * options.buttonXMargin );
    var listHeight = ( items.length * itemHeight ) + ( 2 * options.listYMargin );
    var listNode = new Rectangle( 0, 0, listWidth, listHeight, options.listCornerRadius, options.listCornerRadius,
      { fill: options.listFill, stroke: options.listStroke, lineWidth: options.listLineWidth, visible: false } );
    listParent.addChild( listNode );

    //TODO move these to ItemNode
    // how to highlight an item in the list
    var highlightItem = function( itemNode ) {
      itemNode.fill = options.itemHighlightFill;
      itemNode.stroke = options.itemHighlightStroke;
    };
    var unhighlightItem = function( itemNode ) {
      itemNode.fill = null;
      itemNode.stroke = null;
    };

    // listener that we'll attach to each item in the list
    var itemListener = {
      enter: function( event ) {
        highlightItem( event.currentTarget );
      },
      exit: function( event ) {
        unhighlightItem( event.currentTarget );
      },
      down: function( event ) {
        event.abort(); // prevent click-to-dismiss on the list
      },
      up: function( event ) {

        self.trigger1( 'startedCallbacksForItemFired', event.currentTarget.item.value );

        unhighlightItem( event.currentTarget );
        listNode.visible = false; // close the list, do this before changing property value, in case it's expensive
        self.getUniqueTrail().rootNode().removeInputListener( clickToDismissListener ); // remove the click-to-dismiss listener
        event.abort(); // prevent nodes (eg, controls) behind the list from receiving the event
        property.value = event.currentTarget.item.value; // set the property

        self.trigger0( 'endedCallbacksForItemFired' );
      }
    };

    // populate list with items
    for ( var j = 0; j < items.length; j++ ) {

      var itemNodeOptions = items[ j ].options || {};

      // Create tandems for each ItemNode
      var itemNodeTandem = null;
      // We don't want assert if running in phet brand, same if as Tandem.validateOptions
      if (phet.chipper.brand === 'phet-io' && phet.chipper.queryParameters[ 'phet-io.validateTandems' ]){
        assert && assert( itemNodeOptions.tandemName, 'For instrumented ComboBoxes, ItemNodes must have a tandemName' );
        itemNodeTandem = options.tandem.createTandem( itemNodeOptions.tandemName );
        itemNodeOptions.tandem = itemNodeTandem;
      }

      // Create the list item node itself
      var itemNode = new ItemNode( items[ j ], itemWidth, itemHeight, options.itemXMargin, itemNodeOptions );

      // add item to list
      listNode.addChild( itemNode );
      itemNode.left = options.buttonXMargin;
      itemNode.top = options.listYMargin + ( j * itemHeight );

      // item interactivity
      itemNode.cursor = 'pointer';
      itemNode.addInputListener( itemListener );
    }

    //TODO handle scale and rotation
    // Handles the coordinate transform required to make the list pop up near the button.
    var moveList = function() {
      var pButtonGlobal;
      var pButtonLocal;
      if ( options.listPosition === 'above' ) {
        pButtonGlobal = self.localToGlobalPoint( new Vector2( buttonNode.left, buttonNode.top ) );
        pButtonLocal = listParent.globalToLocalPoint( pButtonGlobal );
        listNode.left = pButtonLocal.x;
        listNode.bottom = pButtonLocal.y;
      }
      else {
        pButtonGlobal = self.localToGlobalPoint( new Vector2( buttonNode.left, buttonNode.bottom ) );
        pButtonLocal = listParent.globalToLocalPoint( pButtonGlobal );
        listNode.left = pButtonLocal.x;
        listNode.top = pButtonLocal.y;
      }
    };

    /**
     * Because clickToDismissListener is added to the scene, it receives the 'down' event that
     * buttonNode received to register the listener. This is because scenery propagates events
     * up the event trail, and the scene is further up the trail than the button.  This flag
     * is used to ignore the first 'down' event, which is the one that the button received.
     * If we don't do this, then we never see the list because it is immediately popped down.
     * This behavior is may change, and is being discussed in scenery#58.
     */
    var enableClickToDismissListener;
    var sceneNode; // store the node that clickToDismissListener is added to, because the scene may change, see sun#14

    // listener for 'click outside to dismiss'
    var clickToDismissListener = {
      down: function() {
        if ( enableClickToDismissListener ) {

          self.trigger0( 'startedCallbacksForComboBoxDismissed' );

          sceneNode.removeInputListener( clickToDismissListener );
          listNode.visible = false;

          self.trigger0( 'endedCallbacksForComboBoxDismissed' );
        }
        else {
          enableClickToDismissListener = true;
        }
      }
    };

    // button interactivity
    buttonNode.cursor = 'pointer';
    buttonNode.addInputListener(
      {
        down: function() {
          if ( !listNode.visible ) {
            self.trigger0( 'startedCallbacksForComboBoxPopupShown' );

            moveList();
            listNode.moveToFront();
            listNode.visible = true;
            enableClickToDismissListener = false;
            sceneNode = self.getUniqueTrail().rootNode();
            sceneNode.addInputListener( clickToDismissListener );

            self.trigger0( 'endedCallbacksForComboBoxPopupShown' );
          }
        }
      } );

    // layout
    if ( options.labelNode ) {
      buttonNode.left = options.labelNode.right + options.labelXSpacing;
      buttonNode.centerY = options.labelNode.centerY;
    }

    // when property changes, update button
    var propertyObserver = function( value ) {
      // TODO brute force search, better way?
      var item = null;
      for ( var i = 0; i < items.length; i++ ) {
        if ( items[ i ].value === value ) {
          item = items[ i ];
        }
      }
      assert && assert( item !== null );
      buttonNode.setItemNode( new ItemNode( item, itemWidth, itemHeight, options.itemXMargin, {
        tandem: options.tandem.createTandem( 'buttonNode', { enabled: false } )
      } ) );
    };
    property.link( propertyObserver );

    this.mutate( options );

    // enable/disable the combo box
    var enabledObserver = function( enabled ) {
      self.pickable = enabled;
      self.opacity = enabled ? 1.0 : options.disabledOpacity;
    };
    this.enabledProperty.link( enabledObserver );

    // @private called by dispose
    this.disposeComboBox = function() {
      self.enabledProperty.unlink( enabledObserver );
      options.tandem && options.tandem.removeInstance( this );
      property.unlink( propertyObserver );
    };

    options.tandem && options.tandem.addInstance( this, TComboBox( property.elementType ) );
  }

  sun.register( 'ComboBox', ComboBox );

  inherit( Node, ComboBox, {

    // @public - Provide dispose() on the prototype for ease of subclassing.
    dispose: function() {
      this.disposeComboBox();
    },

    // @public
    setEnabled: function( enabled ) { this.enabledProperty.value = enabled; },
    set enabled( value ) { this.setEnabled( value ); },

    // @public
    getEnabled: function() { return this.enabledProperty.value; },
    get enabled() { return this.getEnabled(); }
  } );

  /**
   * Creates a combo box item.
   * This exists primarily to document the structure of an item.
   * @param {Node} node
   * @param {*} value
   * @param {Object} [options] Includes but not limited to {tandemName:{string}}, the suffix applied to button tandems
   * @returns {{node:Node, value:*}}
   * @public
   */
  ComboBox.createItem = function( node, value, options ) {
    return { node: node, value: value, options: options };
  };

  /**
   * The button that is clicked to show the list of items.
   * @param {Node} itemNode
   * @param {Object} [options] object with optional properties
   * @constructor
   */
  function ButtonNode( itemNode, options ) {
    Node.call( this );

    // up or down arrow
    var arrow = new TandemPath( null, {
      fill: 'black',
      tandem: options.tandem && options.tandem.createTandem( 'arrow' )
    } );
    var arrowWidth = 0.5 * itemNode.height;
    var arrowHeight = arrowWidth * Math.sqrt( 3 ) / 2; // height of equilateral triangle
    if ( options.listPosition === 'above' ) {
      arrow.shape = new Shape().moveTo( 0, arrowHeight ).lineTo( arrowWidth / 2, 0 ).lineTo( arrowWidth, arrowHeight ).close(); // up arrow
    }
    else {
      arrow.shape = new Shape().moveTo( 0, 0 ).lineTo( arrowWidth, 0 ).lineTo( arrowWidth / 2, arrowHeight ).close(); // down arrow
    }

    // button background
    var width = itemNode.width + ( 4 * options.buttonXMargin ) + arrow.width;
    var height = itemNode.height + ( 2 * options.buttonYMargin );
    var background = new Rectangle( 0, 0, width, height, options.buttonCornerRadius, options.buttonCornerRadius,
      { fill: options.buttonFill, stroke: options.buttonStroke, lineWidth: options.buttonLineWidth } );

    // vertical separator to left of arrow
    var separator = new Line( 0, 0, 0, height, { stroke: 'black', lineWidth: options.buttonLineWidth } );
    options.tandem && options.tandem.createTandem( 'separator' ).addInstance( separator, TNode );

    // itemNode's parent
    var itemNodeParent = new Node();

    // rendering order
    this.addChild( background );
    this.addChild( arrow );
    this.addChild( separator );
    this.addChild( itemNodeParent );

    // @private
    this.setItemNode = function( itemNode ) {
      itemNodeParent.removeAllChildren();
      itemNodeParent.addChild( itemNode );
      itemNode.left = options.buttonXMargin;
      itemNode.top = options.buttonYMargin;
    };
    this.setItemNode( itemNode );

    // layout
    separator.left = itemNode.right + options.buttonXMargin;
    separator.top = background.top;
    arrow.left = separator.right + options.buttonXMargin;
    arrow.centerY = background.centerY;
  }

  sun.register( 'ComboBox.ButtonNode', ButtonNode );

  inherit( Node, ButtonNode );

  /**
   * A wrapper around the combo box item, adds margins, etc.
   * @param {*} item - see ComboBox.createItem
   * @param {number} width
   * @param {number} height
   * @param {number} xMargin
   * @param {Object} [options]
   * @constructor
   * @private
   */
  function ItemNode( item, width, height, xMargin, options ) {
    options = _.extend( {}, options );
    TandemRectangle.call( this, 0, 0, width, height, { tandem: options.tandem } );
    this.item = item;
    this.addChild( item.node );
    item.node.pickable = false; // hits will occur on the rectangle
    item.node.x = xMargin;
    item.node.centerY = height / 2;
  }

  sun.register( 'ComboBox.ItemNode', ItemNode );

  inherit( TandemRectangle, ItemNode );

  return ComboBox;
} );<|endoftext|>


<commit_before>var commandMap = {
  ESCAPE:'KEY_ESC',
  MENU:'229',
  ESC:'KEY_LEFT_ESC',
  END:'KEY_END',
  SPACE:'\' \'',
  TAB:'KEY_TAB',
  PRINTSCREEN:'206',
  ENTER:'KEY_RETURN',
  UPARROW:'KEY_UP_ARROW',
  DOWNARROW:'KEY_DOWN_ARROW',
  LEFTARROW:'KEY_LEFT_ARROW',
  RIGHTARROW:'KEY_RIGHT_ARROW',
  CAPSLOCK:'KEY_CAPS_LOCK',
  DELETE:'KEY_DELETE',
  DEL:'KEY_DELETE',
  F1:'KEY_F1',
  F2:'KEY_F2',
  F3:'KEY_F3',
  F4:'KEY_F4',
  F5:'KEY_F5',
  F6:'KEY_F6',
  F7:'KEY_F7',
  F8:'KEY_F8',
  F9:'KEY_F9',
  F10:'KEY_F10',
  F11:'KEY_F11',
  F12:'KEY_F12'
};

var comboMap = {
  ALT:'KEY_LEFT_ALT',
  GUI:'KEY_LEFT_GUI',
  WINDOWS:'KEY_LEFT_GUI',
  COMMAND:'KEY_LEFT_GUI',
  CTRL:'KEY_LEFT_CTRL',
  CONTROL:'KEY_LEFT_CTRL',
  SHIFT:'KEY_LEFT_SHIFT'
};

var keyMap = {
  a:'97',
  b:'98',
  c:'99',
  d:'100',
  e:'101',
  f:'102',
  g:'103',
  h:'104',
  i:'105',
  j:'106',
  k:'107',
  l:'108',
  m:'109',
  n:'110',
  o:'111',
  p:'112',
  q:'113',
  r:'114',
  s:'115',
  t:'116',
  u:'117',
  v:'118',
  w:'119',
  x:'120',
  y:'121',
  z:'122'
};

class Duckuino {
  constructor() {
    this.keyMap = keyMap;
    this.commandMap = commandMap;
    this.comboMap = comboMap;
  }

  compile(inputCode){
    // Check if there is any code input at all
    if (inputCode == '' || inputCode == undefined)
    {
      console.error('Error: No ducky script was entered!');
      return 'Error, look at the console...';
    }

    var parsedDucky = this.parser(inputCode);
    if (parsedDucky == '' || parsedDucky == undefined)
    {
      return 'Error, look at the console...';
    }

    // Build the Arduino code skeleton
    return '// Init function\n'
    + 'void setup()\n'
    + '{\n'
    + '  // Start Keyboard and Mouse\n'
    + '  Mouse.begin();\n'
    + '  Keyboard.begin();\n\n'
    + '  // Start Payload\n'
    + parsedDucky
    + '\n'
    + '  // End Payload\n\n'
    + '  // Utility function\n'
    + '  void typeKey(int key){\n'
    + '    Keyboard.press(key);\n'
    + '    delay(50);\n'
    + '    Keyboard.release(key);\n'
    + '  }\n\n'
    + '  // Stop Keyboard and Mouse\n'
    + '  Keyboard.end();\n'
    + '  Mouse.end();\n'
    + '}\n'
    + '\n'
    + '// Unused\n'
    + 'void loop() {}';
  }

  // The parsing function
  parser(toParse){
    // Init chronometer
    var timerStart = Date.now();

    var parsedScript = '';

    // Trim whitespaces
    toParse = toParse.replace(/^ +| +$/gm, "");

    // Cuting the input in lines
    var lineArray = toParse.split('\n');

    // Loop every line
    for (var i = 0; i < lineArray.length; i++)
    {
      // Line empty, skip
      if (lineArray[i] === '' || lineArray[i] === '\n')
      {
        console.log('Info: Skipped line ' + (i + 1) + ', because was empty.');
        continue;
      }

      // Var who indicates to release all at the line end
      var releaseAll = false;

      // Outputs, for REPLAY/REPEAT COMMANDS
      if (parsedOut !== undefined && parsedOut !== '')
      {
        var lastLines = parsedOut;
        var lastCount = ((lastLines.split('\n')).length + 1);
      }
      var parsedOut = '';

      // Command known
      var commandKnown = false;

      // Cutting every line in words
      var wordArray = lineArray[i].split(' ');
      var wordOne = wordArray[0];

      // Handle commands
      switch(wordOne){
        case "STRING":
          wordArray.shift();

          var textString = wordArray.join(' ');

          // Replace all '"' by '\"' and all '\' by '\\'
          textString = textString.split('\\').join('\\\\').split('"').join('\\"');
          if (textString !== '')
          {
            parsedOut += '  Keyboard.print("' + textString + '");\n';
            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', STRING needs a text');
            return;
          }
          break;
        case "DELAY":
          wordArray.shift();

          if(wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', DELAY needs a time');
            return;
          }

          if (! isNaN(wordArray[0]))
          {
            parsedOut += '  delay(' + wordArray[0] + ');\n';
            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', DELAY only acceptes numbers');
            return;
          }
          break;
        case "TYPE":
          wordArray.shift();

          if(wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', TYPE needs a key');
            return;
          }

          if (keyMap[wordArray[0]] !== undefined)
          {
            commandKnown = true;
            // Replace the DuckyScript key by the Arduino key name
            parsedOut += '  typeKey(' + keyMap[wordArray[0]] + ');\n';
          } else {
            console.error('Error: Unknown letter \'' + wordArray[0] +'\' at line: ' + (i + 1));
            return;
          }
          break;
        case "REM":
          wordArray.shift();

          // Placing the comment to arduino code
          if (wordArray[0] !== undefined && wordArray[0] !== '')
          {
            commandKnown = true;
            parsedOut += '  // ' + wordArray.join(' ') + '\n';
          } else {
            console.error('Error: at line: ' + (i + 1) + ', REM needs a comment');
            return;
          }
          break;
        case "MOUSEMOVE":
          wordArray.shift();
          if (wordArray[0] != undefined && wordArray[0] != ''){
            commandKnown = true;
            var mouseParams = wordArray[0].split(',');
            parsedOut += '  Mouse.move('+mouseParams[0]+', '+mouseParams[1];

            if(mouseParams[2] != undefined && mouseParams[2] != ''){
              parsedOut += ', '+mouseParams[2];
            }

            parsedOut += ');\n';
            wordArray.shift();
          } else {
            console.error('Error: at line: ' + (i + 1) + ', MOUSEMOVE requires at least two parameters')
            return;
          }
          break;
        case "MOUSECLICK":
          wordArray.shift();
          wordArray[0] = wordArray[0].toUpperCase();

          if (wordArray[0] == 'LEFT' || wordArray[0] == 'RIGHT' || wordArray[0] == 'MIDDLE' && wordArray[0] != undefined && wordArray[0] != ''){
            commandKnown = true;
            parsedOut += '  Mouse.click(MOUSE_'+wordArray[0]+');\n'
            wordArray.shift();
          } else {
            console.error('Error: at line: ' + (i + 1) + ', MOUSECLICK requires key (left/middle/right)')
            return;
          }
          break;
        case "REPEAT":
        case "REPLAY":
          wordArray.shift();

          if (wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', REPEAT/REPLAY needs a loop count');
            return;
          }

          if (lastLines === undefined)
          {
            console.error('Error: at line: ' + (i + 1) + ', nothing to repeat, this is the first line.');
            return;
          }

          if (! isNaN(wordArray[0]))
          {
            // Remove the lines we just created
            var linesTmp = parsedScript.split('\n');
            linesTmp.splice(-lastCount, lastCount);

            if (linesTmp.join('\n') === '')
              parsedScript = linesTmp.join('\n');
            else {
              parsedScript = linesTmp.join('\n') + '\n';
            }

            // Add two spaces at Begining
            lastLines = lastLines.replace(/^  /gm,'    ');

            // Replace them
            parsedOut += '  for(int i = 0; i < ' + wordArray[0] + '; i++) {\n';
            parsedOut += lastLines;
            parsedOut += '  }\n';

            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', REPEAT/REPLAY only acceptes numbers');
            return;
          }
          break;
        default:
          if (wordArray.length == 1)
          {
            if (comboMap[wordArray[0]] !== undefined)
            {
              commandKnown = true;

              parsedOut += '  typeKey(' + comboMap[wordArray[0]] + ');\n';
            }else if (commandMap[wordArray[0]] !== undefined) {
              commandKnown = true;

              parsedOut += '  typeKey(' + commandMap[wordArray[0]] + ');\n';
            }else {
              commandKnown = false;
              break;
            }
            wordArray.shift();
          }
          while (wordArray.length){
            if (comboMap[wordArray[0]] !== undefined)
            {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + comboMap[wordArray[0]] + ');\n';
            }else if (commandMap[wordArray[0]] !== undefined) {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + commandMap[wordArray[0]] + ');\n';
            }else if (keyMap[wordArray[0]] !== undefined) {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + keyMap[wordArray[0]] + ');\n';
            }else {
              commandKnown = false;
              break;
            }
            wordArray.shift();
          }
      }

      if (!commandKnown)
      {
        console.error('Error: Unknown command or key \'' + wordArray[0] + '\' at line: ' + (i + 1) + '.');
        return;
      }

      // If we need to release keys, we do
      if (releaseAll)
        parsedOut += '  Keyboard.releaseAll();\n';

      parsedScript += parsedOut; // Add what we parsed
      if (i != (lineArray.length - 1))
        parsedScript += '\n'; // Add new line if not the last line
    }

    var timerEnd = Date.now();
    var timePassed = new Date(timerEnd - timerStart);

    console.log('Successfuly parsed ' + (lineArray.length) + ' lines in ' + timePassed.getMilliseconds() + 'ms');
    return parsedScript;
  }
}
<commit_msg>Fixed libraries<commit_after>var commandMap = {
  ESCAPE:'KEY_ESC',
  MENU:'229',
  ESC:'KEY_LEFT_ESC',
  END:'KEY_END',
  SPACE:'\' \'',
  TAB:'KEY_TAB',
  PRINTSCREEN:'206',
  ENTER:'KEY_RETURN',
  UPARROW:'KEY_UP_ARROW',
  DOWNARROW:'KEY_DOWN_ARROW',
  LEFTARROW:'KEY_LEFT_ARROW',
  RIGHTARROW:'KEY_RIGHT_ARROW',
  CAPSLOCK:'KEY_CAPS_LOCK',
  DELETE:'KEY_DELETE',
  DEL:'KEY_DELETE',
  F1:'KEY_F1',
  F2:'KEY_F2',
  F3:'KEY_F3',
  F4:'KEY_F4',
  F5:'KEY_F5',
  F6:'KEY_F6',
  F7:'KEY_F7',
  F8:'KEY_F8',
  F9:'KEY_F9',
  F10:'KEY_F10',
  F11:'KEY_F11',
  F12:'KEY_F12'
};

var comboMap = {
  ALT:'KEY_LEFT_ALT',
  GUI:'KEY_LEFT_GUI',
  WINDOWS:'KEY_LEFT_GUI',
  COMMAND:'KEY_LEFT_GUI',
  CTRL:'KEY_LEFT_CTRL',
  CONTROL:'KEY_LEFT_CTRL',
  SHIFT:'KEY_LEFT_SHIFT'
};

var keyMap = {
  a:'97',
  b:'98',
  c:'99',
  d:'100',
  e:'101',
  f:'102',
  g:'103',
  h:'104',
  i:'105',
  j:'106',
  k:'107',
  l:'108',
  m:'109',
  n:'110',
  o:'111',
  p:'112',
  q:'113',
  r:'114',
  s:'115',
  t:'116',
  u:'117',
  v:'118',
  w:'119',
  x:'120',
  y:'121',
  z:'122'
};

class Duckuino {
  constructor() {
    this.keyMap = keyMap;
    this.commandMap = commandMap;
    this.comboMap = comboMap;
  }

  compile(inputCode){
    // Check if there is any code input at all
    if (inputCode == '' || inputCode == undefined)
    {
      console.error('Error: No ducky script was entered!');
      return 'Error, look at the console...';
    }

    var parsedDucky = this.parser(inputCode);
    if (parsedDucky == '' || parsedDucky == undefined)
    {
      return 'Error, look at the console...';
    }

    // Build the Arduino code skeleton
    return '#<include <Keyboad.h>\n'
    + '#<include <Mouse.h>\n'
    + '  // Utility function\n'
    + '  void typeKey(int key){\n'
    + '    Keyboard.press(key);\n'
    + '    delay(50);\n'
    + '    Keyboard.release(key);\n'
    + '  }\n\n'
    + 'void setup()\n'
    + '{\n'
    + '  // Start Keyboard and Mouse\n'
    + '  Mouse.begin();\n'
    + '  Keyboard.begin();\n\n'
    + '  // Start Payload\n'
    + parsedDucky
    + '\n'
    + '  // End Payload\n\n'
    + '  // Stop Keyboard and Mouse\n'
    + '  Keyboard.end();\n'
    + '  Mouse.end();\n'
    + '}\n'
    + '\n'
    + '// Unused\n'
    + 'void loop() {}';
  }

  // The parsing function
  parser(toParse){
    // Init chronometer
    var timerStart = Date.now();

    var parsedScript = '';

    // Trim whitespaces
    toParse = toParse.replace(/^ +| +$/gm, "");

    // Cuting the input in lines
    var lineArray = toParse.split('\n');

    // Loop every line
    for (var i = 0; i < lineArray.length; i++)
    {
      // Line empty, skip
      if (lineArray[i] === '' || lineArray[i] === '\n')
      {
        console.log('Info: Skipped line ' + (i + 1) + ', because was empty.');
        continue;
      }

      // Var who indicates to release all at the line end
      var releaseAll = false;

      // Outputs, for REPLAY/REPEAT COMMANDS
      if (parsedOut !== undefined && parsedOut !== '')
      {
        var lastLines = parsedOut;
        var lastCount = ((lastLines.split('\n')).length + 1);
      }
      var parsedOut = '';

      // Command known
      var commandKnown = false;

      // Cutting every line in words
      var wordArray = lineArray[i].split(' ');
      var wordOne = wordArray[0];

      // Handle commands
      switch(wordOne){
        case "STRING":
          wordArray.shift();

          var textString = wordArray.join(' ');

          // Replace all '"' by '\"' and all '\' by '\\'
          textString = textString.split('\\').join('\\\\').split('"').join('\\"');
          if (textString !== '')
          {
            parsedOut += '  Keyboard.print("' + textString + '");\n';
            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', STRING needs a text');
            return;
          }
          break;
        case "DELAY":
          wordArray.shift();

          if(wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', DELAY needs a time');
            return;
          }

          if (! isNaN(wordArray[0]))
          {
            parsedOut += '  delay(' + wordArray[0] + ');\n';
            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', DELAY only acceptes numbers');
            return;
          }
          break;
        case "TYPE":
          wordArray.shift();

          if(wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', TYPE needs a key');
            return;
          }

          if (keyMap[wordArray[0]] !== undefined)
          {
            commandKnown = true;
            // Replace the DuckyScript key by the Arduino key name
            parsedOut += '  typeKey(' + keyMap[wordArray[0]] + ');\n';
          } else {
            console.error('Error: Unknown letter \'' + wordArray[0] +'\' at line: ' + (i + 1));
            return;
          }
          break;
        case "REM":
          wordArray.shift();

          // Placing the comment to arduino code
          if (wordArray[0] !== undefined && wordArray[0] !== '')
          {
            commandKnown = true;
            parsedOut += '  // ' + wordArray.join(' ') + '\n';
          } else {
            console.error('Error: at line: ' + (i + 1) + ', REM needs a comment');
            return;
          }
          break;
        case "MOUSEMOVE":
          wordArray.shift();
          if (wordArray[0] != undefined && wordArray[0] != ''){
            commandKnown = true;
            var mouseParams = wordArray[0].split(',');
            parsedOut += '  Mouse.move('+mouseParams[0]+', '+mouseParams[1];

            if(mouseParams[2] != undefined && mouseParams[2] != ''){
              parsedOut += ', '+mouseParams[2];
            }

            parsedOut += ');\n';
            wordArray.shift();
          } else {
            console.error('Error: at line: ' + (i + 1) + ', MOUSEMOVE requires at least two parameters')
            return;
          }
          break;
        case "MOUSECLICK":
          wordArray.shift();
          wordArray[0] = wordArray[0].toUpperCase();

          if (wordArray[0] == 'LEFT' || wordArray[0] == 'RIGHT' || wordArray[0] == 'MIDDLE' && wordArray[0] != undefined && wordArray[0] != ''){
            commandKnown = true;
            parsedOut += '  Mouse.click(MOUSE_'+wordArray[0]+');\n'
            wordArray.shift();
          } else {
            console.error('Error: at line: ' + (i + 1) + ', MOUSECLICK requires key (left/middle/right)')
            return;
          }
          break;
        case "REPEAT":
        case "REPLAY":
          wordArray.shift();

          if (wordArray[0] === undefined || wordArray[0] === '') {
            console.error('Error: at line: ' + (i + 1) + ', REPEAT/REPLAY needs a loop count');
            return;
          }

          if (lastLines === undefined)
          {
            console.error('Error: at line: ' + (i + 1) + ', nothing to repeat, this is the first line.');
            return;
          }

          if (! isNaN(wordArray[0]))
          {
            // Remove the lines we just created
            var linesTmp = parsedScript.split('\n');
            linesTmp.splice(-lastCount, lastCount);

            if (linesTmp.join('\n') === '')
              parsedScript = linesTmp.join('\n');
            else {
              parsedScript = linesTmp.join('\n') + '\n';
            }

            // Add two spaces at Begining
            lastLines = lastLines.replace(/^  /gm,'    ');

            // Replace them
            parsedOut += '  for(int i = 0; i < ' + wordArray[0] + '; i++) {\n';
            parsedOut += lastLines;
            parsedOut += '  }\n';

            commandKnown = true;
          } else {
            console.error('Error: at line: ' + (i + 1) + ', REPEAT/REPLAY only acceptes numbers');
            return;
          }
          break;
        default:
          if (wordArray.length == 1)
          {
            if (comboMap[wordArray[0]] !== undefined)
            {
              commandKnown = true;

              parsedOut += '  typeKey(' + comboMap[wordArray[0]] + ');\n';
            }else if (commandMap[wordArray[0]] !== undefined) {
              commandKnown = true;

              parsedOut += '  typeKey(' + commandMap[wordArray[0]] + ');\n';
            }else {
              commandKnown = false;
              break;
            }
            wordArray.shift();
          }
          while (wordArray.length){
            if (comboMap[wordArray[0]] !== undefined)
            {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + comboMap[wordArray[0]] + ');\n';
            }else if (commandMap[wordArray[0]] !== undefined) {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + commandMap[wordArray[0]] + ');\n';
            }else if (keyMap[wordArray[0]] !== undefined) {
              commandKnown = true;
              releaseAll = true;

              parsedOut += '  Keyboard.press(' + keyMap[wordArray[0]] + ');\n';
            }else {
              commandKnown = false;
              break;
            }
            wordArray.shift();
          }
      }

      if (!commandKnown)
      {
        console.error('Error: Unknown command or key \'' + wordArray[0] + '\' at line: ' + (i + 1) + '.');
        return;
      }

      // If we need to release keys, we do
      if (releaseAll)
        parsedOut += '  Keyboard.releaseAll();\n';

      parsedScript += parsedOut; // Add what we parsed
      if (i != (lineArray.length - 1))
        parsedScript += '\n'; // Add new line if not the last line
    }

    var timerEnd = Date.now();
    var timePassed = new Date(timerEnd - timerStart);

    console.log('Successfuly parsed ' + (lineArray.length) + ' lines in ' + timePassed.getMilliseconds() + 'ms');
    return parsedScript;
  }
}
<|endoftext|>