Source: ui/widget/form.js

import {SpeechInput} from '../../core/speech-input.js';

/**
 * Base form helper class contains utility methods for creation of UI elements - text blocks, checkboxes, text input etc.
 * All elements share the same style defined in constructor.
 * Every property of the form or element can be overriden with properties of passed params object.
 * UI elements need to be named; this name is used to create a speech command that activates the element.
 * E.g. if the element is checkbox named 'rigged', it can be (de)selected by speaking 'rigged on' or 'rigged off'.
 * HUD delegates gamepad events to appropriate form methods, making form elements usable.
 */
export class Form {
  constructor(params) {
    this.fontSize = 42;
    this.heightInPixels = 48;
    this.resizeToFit = true;
    this.color = "white";
    this.background = "black";
    this.selected = "yellow";
    this.submitColor = "green";
    this.verticalPanel = false;
    this.inputWidth = 500;
    this.padding = 0;
    this.keyboardRows = null;
    this.virtualKeyboardEnabled = true;
    this.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_CENTER;
    this.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER;
    this.speechInput = new SpeechInput();
    this.speechInput.addNoMatch((phrases)=>this.noMatch(phrases));
    this.inputFocusListener = null;
    this.elements = [];
    this.controls = [];
    this.activeControl = null;
    this.activeBackground = null;
    this.plane = null;
    this.texture = null;
    if ( params ) {
      for(var c of Object.keys(params)) {
        this[c] = params[c];
      }
    }
  }
  /** Returns Form, required by HUD*/
  getClassName() {
    return "Form";
  }
  /** Called by default on speech recognition mismatch */
  noMatch(phrases) {
    console.log('no match:',phrases)
  }
  /**
   * Returns new StackPanel with 1 height and width and aligned to center both vertically and horizontally
   */
  createPanel() {
    this.panel = new BABYLON.GUI.StackPanel();
    this.panel.isVertical = this.verticalPanel;
    this.panel.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_CENTER;
    this.panel.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER;
    this.panel.width = 1;
    this.panel.height = 1;
    return this.panel;
  }
  /** Add control to the panel */
  addControl(control) {
    this.panel.addControl(control);
  }
  /**
   * Creates and returns a textblock with given text 
   */
  textBlock(text, params) {
    var block = new BABYLON.GUI.TextBlock();
    block.horizontalAlignment = this.horizontalAlignment;
    block.verticalAlignment = this.verticalAlignment;
    block.text = text;
    block.color = this.color;
    block.fontSize = this.fontSize;
    block.heightInPixels = this.heightInPixels;
    block.resizeToFit = this.resizeToFit;
    if ( params ) {
      for(var c of Object.keys(params)) {
        block[c] = params[c];
      }
    }
    this.elements.push(block);
    return block;
  }
  // common code for checkbox and radio button
  _common(obj, name, params) {
    obj.heightInPixels = this.heightInPixels;
    obj.widthInPixels = this.heightInPixels;
    obj.color = this.color;
    obj.background = this.background;
    if ( params ) {
      for(var c of Object.keys(params)) {
        obj[c] = params[c];
      }
    }
    let command = this.nameToCommand(name);
    if ( command ) {
      this.speechInput.addCommand(command, (text) => {
        if ( text == 'on' || text == 'true') {
          obj.isChecked = true;
        } else if ( text == 'off' || text == 'false') {
          obj.isChecked = false;
        } else {
          console.log("Can't set "+name+" to "+text);
        }
      }, "*onoff");
    }
    this.elements.push(obj);
    this.controls.push(obj);
    return obj;
  }
  /**
   * Creates and returns a named Checkbox
   */
  checkbox(name, params) {
    var checkbox = new BABYLON.GUI.Checkbox();
    return this._common(checkbox, name, params);
  }
  /**
   * Creates and returns a named RadioButton
   */
  radio(name, params) {
    var radioButton= new BABYLON.GUI.RadioButton();
    return this._common(radioButton, name, params);
  }
  /**
   * Creates and returns a named InputText, registers this.inputFocus() as focus/blur listener
   * @param name identifier used for speech input
   * @param params optional object to override InputText field values
   */
  inputText(name, params) {
    let input = new BABYLON.GUI.InputText(name);
    input.widthInPixels = this.inputWidth;
    input.heightInPixels = this.heightInPixels;
    input.fontSizeInPixels = this.fontSize;

    input.color = this.color;
    input.background = this.background;
    input.focusedBackground = this.background;
    input.onFocusObservable.add(i=>this.inputFocused(i,true));
    input.onBlurObservable.add(i=>this.inputFocused(i,false));
    input.disableMobilePrompt = VRSPACEUI.hud.inXR();
    if ( params ) {
      for(let c of Object.keys(params)) {
        input[c] = params[c];
      }
    }
    let command = this.nameToCommand(name);
    if ( command ) {
      this.speechInput.addCommand(command, 
      (text) => { 
        this.input.text = text;
        this.input.onTextChangedObservable.notifyObservers(text); 
        this.input.onBlurObservable.notifyObservers();
      }, 
      "*text");
    }
    this.elements.push(input);
    this.controls.push(input);
    return input;
  }
  /** Common code for submitButton() and textButton() */
  setupButton(button, callback) {
    button.horizontalAlignment = this.horizontalAlignment;
    button.verticalAlignment = this.verticalAlignment;
    button.heightInPixels = this.heightInPixels;
    // CHECKME: hardcoded padding?
    button.paddingLeft = "10px";
    if ( this.padding ) {
      button.setPaddingInPixels(this.padding);
    }
    let command = this.nameToCommand(button.name);
    if ( callback ) {
      if ( command ) {
        this.speechInput.addCommand(command, () => callback(this));
      }
      button.onPointerDownObservable.add( () => callback(this));
    }
    
    this.elements.push(button);
    this.controls.push(button);
  }
  /**
   * Ceates and returns a named submit image-only Button.
   */
  submitButton(name, callback, icon=VRSPACEUI.contentBase+"/content/icons/play.png") {
    let button = BABYLON.GUI.Button.CreateImageOnlyButton(name, icon);
    button.background = this.submitColor;
    button.widthInPixels = this.heightInPixels+10;
    this.setupButton(button, callback);
    return button;
  }

  /** Creates and returns button showing both text and image */
  textButton(name, callback, icon=VRSPACEUI.contentBase+"/content/icons/play.png", color=this.submitColor) {
    let button = BABYLON.GUI.Button.CreateImageButton(name.toLowerCase(), name, icon);
    button.background = color;
    button.widthInPixels = this.heightInPixels/2*name.length+10;
    this.setupButton(button, callback);
    return button;
  }
  /**
   * Creates and returns a VirtualKeyboard, bound to given AdvancedDynamicTexture.
   * A form can only have one keyboard, shared by all InputText elements. 
   * Currently selected InputText takes keyboard input.
   */
  keyboard(advancedTexture = this.texture) {
    var keyboard = BABYLON.GUI.VirtualKeyboard.CreateDefaultLayout('form-keyboard');
    keyboard.fontSizeInPixels = 36;
    keyboard.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
    if (this.keyboardRows) {
      this.keyboardRows.forEach(row=>keyboard.addKeysRow(row));
    }
    advancedTexture.addControl(keyboard);
    keyboard.isVisible = false;
    this.vKeyboard = keyboard;
    return keyboard;
  }

  /**
   * Creates a plane and advanced dynamic texture to hold the panel and all controlls.
   * At this point all UI elements should be created. 
   * TODO Form should estimate required texture width/height from elements
   */
  createPlane(size, textureWidth, textureHeight) {
    this.planeSize = size;
    this.plane = BABYLON.MeshBuilder.CreatePlane("FormPlane", {width: size*textureWidth/textureHeight, height: size});
    this.texture = BABYLON.GUI.AdvancedDynamicTexture.CreateForMesh(this.plane,textureWidth,textureHeight);
    // advancedTexture creates material and attaches it to the plane
    this.plane.material.transparencyMode = BABYLON.Material.MATERIAL_ALPHATEST;
    this.texture.addControl(this.panel);
    return this.plane;
  }
  /** 
   * Connects the keyboard to given input, or hides it
   * @param input InputText to (dis)connect
   * @param focused true = connect the keyboard, false = disconnect and hide 
   */
  inputFocused(input, focused) {
    if ( this.vKeyboard && this.virtualKeyboardEnabled ) {
      if ( focused ) {
        this.vKeyboard.connect(input); // makes keyboard invisible if input has no focus
      } else {
        this.vKeyboard.disconnect(input);
      }
      this.vKeyboard.isVisible=focused;
    }
    if ( this.inputFocusListener ) {
      this.inputFocusListener(input,focused);
    }
  }

  /**
   * Dispose of all created elements.
   */
  dispose() {
    if ( this.vKeyboard ) {
      this.vKeyboard.dispose();
      delete this.vKeyboard;
    }
    this.elements.forEach(e=>e.dispose());
    this.speechInput.dispose();
    if ( this.panel ) {
      this.panel.dispose();
    }
    if ( this.plane ) {
      this.plane.dispose();
    }
    if ( this.texture ) {
      this.texture.dispose();
    }
  }
  
  /** Internal voice input method: converts a control (button,checkbox...) name (label) to voice command */
  nameToCommand(name) {
    let ret = null;
    if ( name ) {
      // split words and remove all punctuation
      let tokens = name.split(/\s+/).map(word => word.replace(/^[^\w]+|[^\w]+$/g, ''));
      if ( tokens ) {
        // first word is mandatory
        let command = tokens[0].toLowerCase();
        if ( tokens.length > 1 ) {
          // all other words are optional
          for ( let i = 1; i < tokens.length; i++ ) {
            command += ' ('+tokens[i].toLowerCase()+')';
          }
        }
        ret = command;
      }
    }
    return ret;
  }
  
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * @return all controls in this form, or in the keyboard if it's active
   */
  getControls() {
    if ( this.activeControl && this.activeControl.getClassName() == "VirtualKeyboard") {
      return this.vKeyboard.children[this.keyboardRow].children;
    }
    return this.controls;
  }
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * @return currently active control, or in the keyboard if it's active
   */
  getActiveControl() {
    if ( this.activeControl && this.activeControl.getClassName() == "VirtualKeyboard") {
      return this.vKeyboard.children[this.keyboardRow].children[this.keyboardCol];
    }
    return this.activeControl;
  }
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * Sets currently active control.
   */
  setActiveControl(control) {
    if ( this.activeControl && this.activeControl.getClassName() == "VirtualKeyboard") {
      return;
    }
    this.activeControl = control;
  }
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * Activates currently selected control, equivalent to clicking/tapping it.
   * E.g. (de)select a checkbox, press a button, etc.
   */
  activateCurrent() {
    if ( this.activeControl ) {
      //console.log('activate '+this.activeControl.getClassName());
      if ( this.activeControl.getClassName() == "Checkbox") {
        this.activeControl.isChecked = !this.activeControl.isChecked;
      } else if ( this.activeControl.getClassName() == "Button") {
        this.activeControl.onPointerDownObservable.observers.forEach(observer=>observer.callback(this));
      } else if ( this.activeControl.getClassName() == "InputText") {
        console.log("activating keyboard");
        this.activeControl.disableMobilePrompt = true;
        // keyboard has 5 children, each with own children;
        this.getActiveControl().background = this.activeBackground;
        this.activeControl = this.vKeyboard;
        this.keyboardRow = 0;
        this.keyboardCol = 0;
        this.selectCurrent(0);
      } else if ( this.activeControl.getClassName() == "VirtualKeyboard") {
        let input = this.activeControl.connectedInputText;
        let button = this.vKeyboard.children[this.keyboardRow].children[this.keyboardCol];
        button.onPointerUpObservable.observers.forEach(o=>{
          o.callback();
        })
        if(!this.vKeyboard.isVisible) {
          // enter key pressed
          this.activeControl = input;
          this.getActiveControl().background = this.selected;
        }
      }
    }
  }
  /**
   * Internal virtual keyboard method, selects current row at given index
   */
  selectCurrent(index) {
    if (this.activeControl) {
      //console.log('select '+index+' '+this.getActiveControl().getClassName());
      this.keyboardCol = index;
      this.activeBackground = this.getActiveControl().background;
      this.getActiveControl().background = this.selected;
      if ( this.getActiveControl().getClassName() == "InputText") {
        this.inputFocused(this.getActiveControl(),true);
      }
    }
  }
  /**
   * Deselects current control, i.e. changes the background color
   */
  unselectCurrent() {
    if (this.activeControl) {
      this.getActiveControl().background = this.activeBackground;
      if ( this.getActiveControl().getClassName() == "InputText") {
        this.inputFocused(this.activeControl,false);
      }
    }
  }
  /**
   * Internal virtual keyboard method, keeps column index in range
   */
  adjustKeyboardColumn() {
    if (this.keyboardCol >= this.vKeyboard.children[this.keyboardRow].children.length-1) {
      this.keyboardCol = this.vKeyboard.children[this.keyboardRow].children.length-1;
    }
    this.selectCurrent(this.keyboardCol);
  }
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * Processes up key: activate current element, or move up a row in virtual keyboard
   */
  up() {
    if ( this.activeControl && this.activeControl.getClassName() == "VirtualKeyboard") {
      this.unselectCurrent();
      if ( this.keyboardRow > 0 ) {
        this.keyboardRow--;
      } else {
        this.keyboardRow = this.vKeyboard.children.length-1;
      }
      this.adjustKeyboardColumn();
    } else {
      this.activateCurrent();
    }
  }
  /**
   * Input delegate method used for gamepad input, or programatic control of the form.
   * Processes down key: move down a row in virtual keyboard
   */
  down() {
    if ( this.activeControl && this.activeControl.getClassName() == "VirtualKeyboard") {
      this.unselectCurrent();
      if ( this.keyboardRow + 1 < this.vKeyboard.children.length ) {
        this.keyboardRow++;
      } else {
        this.keyboardRow = 0;
      }
      this.adjustKeyboardColumn();
      return false;
    }
    return true;
  }
  /**
   * XR selection support
   */
  isSelectableMesh(mesh) {
    return this.plane && this.plane == mesh;
  }
}