<NME> shader_controls.ts
<BEF> /**
 * @license
 * Copyright 2019 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import debounce from 'lodash/debounce';
import {TrackableRGB} from 'neuroglancer/util/color';
import {RefCounted} from 'neuroglancer/util/disposable';
import {removeChildren} from 'neuroglancer/util/dom';
import {verifyObjectProperty, verifyString} from 'neuroglancer/util/json';
import {ColorWidget} from 'neuroglancer/widget/color';
import {RangeWidget} from 'neuroglancer/widget/range';


export class ShaderControls extends RefCounted {
  element = document.createElement('div');
  private controlDisposer: RefCounted|undefined = undefined;

  constructor(public state: ShaderControlState) {
    super();
    const {controls} = state;
    this.registerDisposer(
        controls.changed.add(this.registerCancellable(debounce(() => this.updateControls(), 0))));
}
  }

  updateControls() {
    if (this.controlDisposer !== undefined) {
      this.controlDisposer.dispose();
      removeChildren(this.element);
    }
    const controlDisposer = this.controlDisposer = new RefCounted();
    for (const [name, controlState] of this.state.state) {
      const {control} = controlState;
      switch (control.type) {
        case 'slider': {
          const widget = controlDisposer.registerDisposer(new RangeWidget(
              controlState.trackable, {min: control.min, max: control.max, step: control.step}));
          widget.promptElement.textContent = name;
          this.element.appendChild(widget.element);
          break;
        }
        case 'color': {
          const label = document.createElement('label');
          label.textContent = name;
          const widget = controlDisposer.registerDisposer(
              new ColorWidget(controlState.trackable as TrackableRGB));
          this.element.appendChild(label);
          label.appendChild(widget.element);
          break;
        }
      }
        if (otherType === 'imageInvlerp') ++histogramIndex;
      }
      return channelInvlerpLayerControl(
          () => ({
            dataType: control.dataType,
            defaultChannel: control.default.channel,
            watchableValue: controlState.trackable,
            channelCoordinateSpaceCombiner: shaderControlState.channelCoordinateSpaceCombiner,
            histogramSpecifications: shaderControlState.histogramSpecifications,
            histogramIndex,
            legendShaderOptions: layerShaderControls.legendShaderOptions,
          }));
    }
    case 'propertyInvlerp': {
      let histogramIndex = 0;
      for (const [otherName, {control: {type: otherType}}] of shaderControlState.state) {
        if (otherName === controlId) break;
        if (otherType === 'propertyInvlerp') ++histogramIndex;
      }
      return propertyInvlerpLayerControl(
          () => ({
            properties: control.properties,
            watchableValue: controlState.trackable,
            histogramSpecifications: shaderControlState.histogramSpecifications,
            histogramIndex,
            legendShaderOptions: layerShaderControls.legendShaderOptions,
          }));
    }
  }
}

function getShaderLayerControlDefinition<LayerType extends UserLayer>(
    getter: (layer: LayerType) => LayerShaderControls, toolId: string,
    controlId: string): LayerControlDefinition<LayerType> {
  return {
    label: controlId,
    toolJson: shaderControlToolJson(controlId, toolId),
    makeControl: (layer, context, options) => {
      const layerShaderControls = getter(layer);
      return getShaderLayerControlFactory(layerShaderControls, controlId)!.makeControl(
          layer, context, options);
    },
    activateTool: (activation, control) => {
      const layerShaderControls = getter(activation.tool.layer);
      return getShaderLayerControlFactory(layerShaderControls, controlId)!.activateTool(
          activation, control);
    },
  };
}

export class ShaderControls extends Tab {
  private controlDisposer: RefCounted|undefined = undefined;
  private toolId: string;
  constructor(
      public state: ShaderControlState, public display: DisplayContext, public layer: UserLayer,
      public options: ShaderControlsOptions = {}) {
    super(options.visibility);
    const {toolId = SHADER_CONTROL_TOOL_ID} = options;
    this.toolId = toolId;
    const {element} = this;
    element.style.display = 'contents';
    const {controls} = state;
    this.registerDisposer(
        controls.changed.add(this.registerCancellable(debounce(() => this.updateControls(), 0))));
    this.updateControls();
  }

  updateControls() {
    const {element} = this;
    if (this.controlDisposer !== undefined) {
      this.controlDisposer.dispose();
      removeChildren(element);
    }
    const controlDisposer = this.controlDisposer = new RefCounted();
    const layerShaderControlsGetter = () => ({
      shaderControlState: this.state,
      legendShaderOptions: this.options.legendShaderOptions,
    });
    for (const name of this.state.state.keys()) {
      element.appendChild(addLayerControlToOptionsTab(
          controlDisposer, this.layer, this.visibility,
          getShaderLayerControlDefinition(layerShaderControlsGetter, this.toolId, name)));
    }
  }

  disposed() {
    this.controlDisposer?.dispose();
    super.disposed();
  }
}

interface LayerShaderControls {
  shaderControlState: ShaderControlState;
  legendShaderOptions?: LegendShaderOptions;
}

export const SHADER_CONTROL_TOOL_ID = 'shaderControl';
const CONTROL_JSON_KEY = 'control';

function shaderControlToolJson(control: string, toolId: string) {
  return {type: toolId, [CONTROL_JSON_KEY]: control};
}

class ShaderControlTool extends LayerControlTool {
  constructor(
      layer: UserLayer, private layerShaderControls: LayerShaderControls, toolId: string,
      private control: string) {
    super(layer, getShaderLayerControlDefinition(() => layerShaderControls, toolId, control));
    this.registerDisposer(layerShaderControls.shaderControlState.controls.changed.add(
        this.registerCancellable(debounce(() => {
          if (layerShaderControls.shaderControlState.state.get(control) === undefined) {
            this.unbind();
          }
        }))));
  }
  activate(activation: ToolActivation<this>) {
    const {shaderControlState} = this.layerShaderControls;
    const controlState = shaderControlState.state.get(this.control);
    if (controlState === undefined) return;
    super.activate(activation);
  }
}

export function registerLayerShaderControlsTool<LayerType extends UserLayer>(
    layerType: UserLayerConstructor&AnyConstructor<LayerType>,
    getter: (layer: LayerType) => LayerShaderControls, toolId: string = SHADER_CONTROL_TOOL_ID) {
  registerLayerTool(layerType, toolId, (layer, options) => {
    const control = verifyObjectProperty(options, CONTROL_JSON_KEY, verifyString);
    return new ShaderControlTool(layer, getter(layer), toolId, control);
  });
}

<MSG> feat(widget/shader_controls): use cleaner grid layout

<DFF> @@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+import './shader_controls.css';
+
 import debounce from 'lodash/debounce';
 import {TrackableRGB} from 'neuroglancer/util/color';
 import {RefCounted} from 'neuroglancer/util/disposable';
@@ -22,13 +24,14 @@ import {ShaderControlState} from 'neuroglancer/webgl/shader_ui_controls';
 import {ColorWidget} from 'neuroglancer/widget/color';
 import {RangeWidget} from 'neuroglancer/widget/range';
 
-
 export class ShaderControls extends RefCounted {
   element = document.createElement('div');
   private controlDisposer: RefCounted|undefined = undefined;
 
   constructor(public state: ShaderControlState) {
     super();
+    const {element} = this;
+    element.classList.add('neuroglancer-shader-controls');
     const {controls} = state;
     this.registerDisposer(
         controls.changed.add(this.registerCancellable(debounce(() => this.updateControls(), 0))));
@@ -36,28 +39,28 @@ export class ShaderControls extends RefCounted {
   }
 
   updateControls() {
+    const {element} = this;
     if (this.controlDisposer !== undefined) {
       this.controlDisposer.dispose();
-      removeChildren(this.element);
+      removeChildren(element);
     }
     const controlDisposer = this.controlDisposer = new RefCounted();
     for (const [name, controlState] of this.state.state) {
       const {control} = controlState;
+      const label = document.createElement('label');
+      label.textContent = name;
+      element.appendChild(label);
       switch (control.type) {
         case 'slider': {
           const widget = controlDisposer.registerDisposer(new RangeWidget(
               controlState.trackable, {min: control.min, max: control.max, step: control.step}));
-          widget.promptElement.textContent = name;
-          this.element.appendChild(widget.element);
+          element.appendChild(widget.element);
           break;
         }
         case 'color': {
-          const label = document.createElement('label');
-          label.textContent = name;
           const widget = controlDisposer.registerDisposer(
               new ColorWidget(controlState.trackable as TrackableRGB));
-          this.element.appendChild(label);
-          label.appendChild(widget.element);
+          element.appendChild(widget.element);
           break;
         }
       }

feat(widget/shader_controls): use cleaner grid layout