Source: ui/widget/buttons.js

import { SpeechInput } from '../../core/speech-input.js';
import { Label } from './label.js';
import { VRSPACEUI } from '../vrspace-ui.js'

/** Menu consisting of vertical buttons in 3D space and associated labels.
 */
export class Buttons {
  /**
  @param scene babylonjs scene
  @param title string displayed above the menu
  @param options array of options, string labels or objects
  @param callback executed when button is activated
  @param property optional, if options are object, this specifies string property to display as label
   */
  constructor(scene, title, options, callback, property) {
    this.scene = scene;
    this.title = title;
    this.options = options;
    this.callback = callback;
    this.property = property;
    this.buttonHeight = 1;
    this.spacing = 1.1;
    /** Can we turn off the current selection? Default false. */
    this.turnOff = false;
    /** Prefix the button with a number? Default false. */
    this.showOptionNumber = false;
    this.color = "white";
    this.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
    this.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_TOP;
    // from here on, state variables:
    //this.background = true; //experimental
    this.group = new BABYLON.TransformNode("ButtonGroup:" + this.title, scene);
    this.groupWidth = 0;
    this.buttons = [];
    this.selectedOption = -1;
    this.controls = [];
    this.textures = [];
    this.materials = [];
    this.pointerTracker = null;
    this.speechInput = new SpeechInput();
    this.speechInput.addNoMatch((phrases) => this.noMatch(phrases));
    //this.display();
  }

  /** Dispose of everything */
  dispose() {
    this.scene.onPointerObservable.remove(this.pointerTracker);
    delete this.selectedMaterial;
    delete this.unselectedMaterial;
    this.group.dispose();
    this.controls.forEach(e => e.dispose());
    this.textures.forEach(e => e.dispose());
    this.materials.forEach(e => e.dispose());
    this.speechInput.dispose();
    console.log("Disposed of buttons " + this.title);
  }

  /** Set the height, rescales the menu */
  setHeight(height) {
    let scale = height / this.options.length;
    this.group.scaling = new BABYLON.Vector3(scale, scale, scale);
  }

  /** Called by default on speech recognition mismatch */
  noMatch(phrases) {
    console.log('no match:', phrases)
  }

  /** Display the menu, adds a pointer observable */
  display() {
    // CHECKME: better use emissive color?
    this.selectedMaterial = new BABYLON.StandardMaterial("selectedButtonMaterial", this.scene);
    this.selectedMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0);
    this.selectedMaterial.emissiveColor = new BABYLON.Color3(.4, .8, .4);
    this.selectedMaterial.disableLighting = true;
    this.materials.push(this.selectedMaterial);
    this.unselectedMaterial = new BABYLON.StandardMaterial("unselectedButtonMaterial", this.scene);
    this.unselectedMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0);
    this.unselectedMaterial.emissiveColor = new BABYLON.Color3(.2, .2, .2);
    this.unselectedMaterial.disableLighting = true;
    this.materials.push(this.unselectedMaterial);

    let commandPrefix = "";
    if (this.title && this.title.length > 0) {
      commandPrefix = this.title + " ";
      let height = 1.5;
      let titleLabel = new Label(this.title, new BABYLON.Vector3(0, this.spacing * height, 0), this.group);
      titleLabel.height = height;
      titleLabel.horizontalAlignment = this.horizontalAlignment;
      titleLabel.verticalAlignment = this.verticalAlignment;
      titleLabel.display();
      this.controls.push(titleLabel);
    }

    for (let i = 0;i < this.options.length;i++) {
      if (this.property) {
        var option = this.options[i][this.property];
      } else {
        var option = this.options[i];
      }
      let command = commandPrefix.toLowerCase();
      if (this.showOptionNumber) {
        let optionNumber = (i + 1).toString();
        option = optionNumber + ". " + option;
        this.speechInput.addCommand(command,
          (number) => {
            if (optionNumber == number && (i != this.selectedOption || this.turnOff)) {
              this.select(i);
            }
          }, 
          "*number"
        );
      } else {
        command += option.toLowerCase();
        this.speechInput.addCommand(command,
          () => {
            if (i != this.selectedOption || this.turnOff) {
              this.select(i);
            }
          }
        );
      }

      this.groupWidth = Math.max(this.groupWidth, option.length);

      let buttonLabel = new Label(option, new BABYLON.Vector3(this.buttonHeight, -i * this.spacing, 0), this.group);
      buttonLabel.horizontalAlignment = this.horizontalAlignment;
      buttonLabel.verticalAlignment = this.verticalAlignment;
      buttonLabel.display();
      this.controls.push(buttonLabel);

      let button = BABYLON.MeshBuilder.CreateCylinder("Button" + option, { height: .1, diameter: this.buttonHeight * .8 }, this.scene);
      button.material = this.unselectedMaterial;
      button.rotation = new BABYLON.Vector3(Math.PI / 2, 0, 0);
      button.position = new BABYLON.Vector3(this.buttonHeight / 2, -i * this.spacing, 0);
      button.parent = this.group;
      button.isNearPickable = VRSPACEUI.allowHands;
      this.buttons.push(button);
    }

    this.pointerTracker = (e) => {
      if (e.type == BABYLON.PointerEventTypes.POINTERDOWN) {
        let p = e.pickInfo;
        for (let i = 0;i < this.options.length;i++) {
          if (p.pickedMesh == this.buttons[i]) {
            // CHECKME we may want to handle double click somehow
            if (i != this.selectedOption || this.turnOff) {
              this.select(i);
            }
            break;
          }
        }
      }
    };

    this.scene.onPointerObservable.add(this.pointerTracker);

    // paints background plane, can't be semi-transparent though
    if (this.background) {
      console.log("Group width: " + this.groupWidth);
      let backgroundWidth = this.groupWidth / 1.8;
      let backgroundHeight = this.options.length * this.spacing;
      let backgroundOffset = this.buttonHeight * .8; // same as button cylinder diameter
      let backPlane = BABYLON.MeshBuilder.CreatePlane("ButtonBackground:" + this.title, { height: backgroundHeight, width: backgroundWidth }, this.scene);
      backPlane.position = new BABYLON.Vector3(backgroundWidth / 2 + backgroundOffset, -backgroundHeight / 2 + this.spacing / 2, .2);
      backPlane.parent = this.group;
      let backgroundMaterial = new BABYLON.StandardMaterial("unselectedButtonMaterial", this.scene);
      backgroundMaterial.disableLighting = true;
      //backgroundMaterial.alpha = 0.5; // produces weird transparency effects
      this.materials.push(backgroundMaterial);
      backPlane.material = backgroundMaterial;
    }

    this.speechInput.start();

  }

  /** Select an option, executed when a button is pressed.
  Executes the callback passing selected option as parameter.
   */
  select(i) {
    console.log("Selected: " + this.options[i].name);
    if (this.callback) {
      this.callback(this.options[i]);
    }
    this.buttons[i].material = this.selectedMaterial;
    if (this.selectedOption > -1) {
      this.buttons[this.selectedOption].material = this.unselectedMaterial;
    }
    if (i != this.selectedOption) {
      this.selectedOption = i;
    } else {
      this.selectedOption = -1;
    }
  }

  // CHECKME: not used so far
  hide() {
    this.group.isEnabled = false;
  }

  show() {
    this.group.isEnabled = true;
  }
}