<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 './shader_controls.css';

import debounce from 'lodash/debounce';
import {DisplayContext} from 'neuroglancer/display_context';
import {Position} from 'neuroglancer/navigation_state';
import {TrackableBoolean, TrackableBooleanCheckbox} from 'neuroglancer/trackable_boolean';
import {arraysEqual} from 'neuroglancer/util/array';
import {TrackableRGB} from 'neuroglancer/util/color';
import {RefCounted} from 'neuroglancer/util/disposable';
import {removeChildren} from 'neuroglancer/util/dom';
import {WatchableVisibilityPriority} from 'neuroglancer/visibility_priority/frontend';
import {ParameterizedEmitterDependentShaderOptions, ParameterizedShaderGetterResult} from 'neuroglancer/webgl/dynamic_shader';
import {ShaderControlState} from 'neuroglancer/webgl/shader_ui_controls';
import {ColorWidget} from 'neuroglancer/widget/color';
import {InvlerpWidget} from 'neuroglancer/widget/invlerp';
import {PositionWidget} from 'neuroglancer/widget/position_widget';
import {RangeWidget} from 'neuroglancer/widget/range';
import {Tab} from 'neuroglancer/widget/tab_view';

export interface LegendShaderOptions extends ParameterizedEmitterDependentShaderOptions {
export interface LegendShaderOptions extends ParameterizedEmitterDependentShaderOptions {
  initializeShader: (shaderResult: ParameterizedShaderGetterResult) => void;
}
export interface ShaderControlsOptions {
  legendShaderOptions?: LegendShaderOptions;
  visibility?: WatchableVisibilityPriority;
}

export class ShaderControls extends Tab {
  private controlDisposer: RefCounted|undefined = undefined;

  constructor(
      public state: ShaderControlState, public display: DisplayContext,
      public options: ShaderControlsOptions = {}) {
    super(options.visibility);
    const {element} = this;
    element.classList.add('neuroglancer-shader-controls');
    const {controls} = state;
    this.registerDisposer(
        controls.changed.add(this.registerCancellable(debounce(() => this.updateControls(), 0))));
}

export class ShaderControls extends Tab {
  private controlDisposer: RefCounted|undefined = undefined;
  private toolId: string;
  constructor(
      public state: ShaderControlState, public display: DisplayContext, public layer: UserLayer,
      removeChildren(element);
    }
    const controlDisposer = this.controlDisposer = new RefCounted();
    let histogramIndex = 0;
    for (const [name, controlState] of this.state.state) {
      const {control} = controlState;
      const labelDiv = document.createElement('div');
      const label = document.createElement('label');
      label.textContent = name;
      labelDiv.appendChild(label);
      element.appendChild(labelDiv);
      switch (control.type) {
        case 'slider': {
          const widget = controlDisposer.registerDisposer(new RangeWidget(
              controlState.trackable, {min: control.min, max: control.max, step: control.step}));
          element.appendChild(widget.element);
          break;
        }
        case 'color': {
          const widget = controlDisposer.registerDisposer(
              new ColorWidget(controlState.trackable as TrackableRGB));
          element.appendChild(widget.element);
          break;
        }
        case 'checkbox': {
          const widget = controlDisposer.registerDisposer(
              new TrackableBooleanCheckbox(controlState.trackable as TrackableBoolean));
          element.appendChild(widget.element);
          break;
        }
        case 'invlerp': {
          const {channelCoordinateSpaceCombiner} = this.state;
          if (channelCoordinateSpaceCombiner !== undefined &&
              control.default.channel.length !== 0) {
            const position = controlDisposer.registerDisposer(
                new Position(channelCoordinateSpaceCombiner.combined));
            const positionWidget = controlDisposer.registerDisposer(
              new PositionWidget(position, channelCoordinateSpaceCombiner, {copyButton: false}));
            const {trackable} = controlState;
            controlDisposer.registerDisposer(position.changed.add(() => {
              const value = position.value;
              const newChannel = Array.from(value, x => Math.floor(x));
              const oldParams = trackable.value;
              if (!arraysEqual(oldParams.channel, newChannel)) {
                trackable.value = {...trackable.value, channel: newChannel};
              }
            }));
            const updatePosition = () => {
              const value = position.value;
              const params = trackable.value;
              if (!arraysEqual(value, params.channel)) {
                value.set(params.channel);
                position.changed.dispatch();
              }
            };
            updatePosition();
            controlDisposer.registerDisposer(trackable.changed.add(updatePosition));
            labelDiv.appendChild(positionWidget.element);
          }
          const widget = controlDisposer.registerDisposer(new InvlerpWidget(
              this.visibility, this.display, control, controlState.trackable,
              this.state.histogramSpecifications, histogramIndex, this.options));
          element.appendChild(widget.element);
          ++histogramIndex;
          break;
        }
      }
    }
  }

    if (this.controlDisposer !== undefined) {
      this.controlDisposer.dispose();
    super.disposed();
  }
}
  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: allow key bindings to be associated with layer controls

Layer controls like opacity and resolution, as well as custom shader
UI controls, can now be bound to shift+letter via the new "tool" key
binding mechanism.

<DFF> @@ -14,23 +14,22 @@
  * limitations under the License.
  */
 
-import './shader_controls.css';
-
 import debounce from 'lodash/debounce';
 import {DisplayContext} from 'neuroglancer/display_context';
-import {Position} from 'neuroglancer/navigation_state';
-import {TrackableBoolean, TrackableBooleanCheckbox} from 'neuroglancer/trackable_boolean';
-import {arraysEqual} from 'neuroglancer/util/array';
-import {TrackableRGB} from 'neuroglancer/util/color';
+import {UserLayer, UserLayerConstructor} from 'neuroglancer/layer';
+import {registerLayerTool, ToolActivation} from 'neuroglancer/ui/tool';
 import {RefCounted} from 'neuroglancer/util/disposable';
 import {removeChildren} from 'neuroglancer/util/dom';
+import {verifyObjectProperty, verifyString} from 'neuroglancer/util/json';
+import {AnyConstructor} from 'neuroglancer/util/mixin';
 import {WatchableVisibilityPriority} from 'neuroglancer/visibility_priority/frontend';
 import {ParameterizedEmitterDependentShaderOptions, ParameterizedShaderGetterResult} from 'neuroglancer/webgl/dynamic_shader';
 import {ShaderControlState} from 'neuroglancer/webgl/shader_ui_controls';
-import {ColorWidget} from 'neuroglancer/widget/color';
-import {InvlerpWidget} from 'neuroglancer/widget/invlerp';
-import {PositionWidget} from 'neuroglancer/widget/position_widget';
-import {RangeWidget} from 'neuroglancer/widget/range';
+import {addLayerControlToOptionsTab, LayerControlDefinition, LayerControlFactory, LayerControlTool} from 'neuroglancer/widget/layer_control';
+import {channelInvlerpLayerControl} from 'neuroglancer/widget/layer_control_channel_invlerp';
+import {checkboxLayerControl} from 'neuroglancer/widget/layer_control_checkbox';
+import {colorLayerControl} from 'neuroglancer/widget/layer_control_color';
+import {rangeLayerControl} from 'neuroglancer/widget/layer_control_range';
 import {Tab} from 'neuroglancer/widget/tab_view';
 
 export interface LegendShaderOptions extends ParameterizedEmitterDependentShaderOptions {
@@ -40,17 +39,76 @@ export interface LegendShaderOptions extends ParameterizedEmitterDependentShader
 export interface ShaderControlsOptions {
   legendShaderOptions?: LegendShaderOptions;
   visibility?: WatchableVisibilityPriority;
+  toolId?: string;
+}
+
+function getShaderLayerControlFactory<LayerType extends UserLayer>(
+    layerShaderControls: LayerShaderControls, controlId: string): LayerControlFactory<LayerType>|
+    undefined {
+  const {shaderControlState} = layerShaderControls;
+  const controlState = shaderControlState.state.get(controlId);
+  if (controlState === undefined) return undefined;
+  const {control} = controlState;
+  switch (control.type) {
+    case 'slider':
+      return rangeLayerControl(() => ({
+                                 value: controlState.trackable,
+                                 options: {min: control.min, max: control.max, step: control.step},
+                               }));
+    case 'color':
+      return colorLayerControl(() => controlState.trackable);
+    case 'checkbox':
+      return checkboxLayerControl(() => controlState.trackable);
+    case 'invlerp': {
+      let histogramIndex = 0;
+      for (const [otherName, {control: {type: otherType}}] of shaderControlState.state) {
+        if (otherName === controlId) break;
+        if (otherType === 'invlerp') ++histogramIndex;
+      }
+      return channelInvlerpLayerControl(
+          () => ({
+            dataType: control.dataType,
+            defaultChannel: control.default.channel,
+            watchableValue: controlState.trackable,
+            channelCoordinateSpaceCombiner: shaderControlState.channelCoordinateSpaceCombiner,
+            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 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.classList.add('neuroglancer-shader-controls');
+    element.style.display = 'contents';
     const {controls} = state;
     this.registerDisposer(
         controls.changed.add(this.registerCancellable(debounce(() => this.updateControls(), 0))));
@@ -64,70 +122,14 @@ export class ShaderControls extends Tab {
       removeChildren(element);
     }
     const controlDisposer = this.controlDisposer = new RefCounted();
-    let histogramIndex = 0;
-    for (const [name, controlState] of this.state.state) {
-      const {control} = controlState;
-      const labelDiv = document.createElement('div');
-      const label = document.createElement('label');
-      label.textContent = name;
-      labelDiv.appendChild(label);
-      element.appendChild(labelDiv);
-      switch (control.type) {
-        case 'slider': {
-          const widget = controlDisposer.registerDisposer(new RangeWidget(
-              controlState.trackable, {min: control.min, max: control.max, step: control.step}));
-          element.appendChild(widget.element);
-          break;
-        }
-        case 'color': {
-          const widget = controlDisposer.registerDisposer(
-              new ColorWidget(controlState.trackable as TrackableRGB));
-          element.appendChild(widget.element);
-          break;
-        }
-        case 'checkbox': {
-          const widget = controlDisposer.registerDisposer(
-              new TrackableBooleanCheckbox(controlState.trackable as TrackableBoolean));
-          element.appendChild(widget.element);
-          break;
-        }
-        case 'invlerp': {
-          const {channelCoordinateSpaceCombiner} = this.state;
-          if (channelCoordinateSpaceCombiner !== undefined &&
-              control.default.channel.length !== 0) {
-            const position = controlDisposer.registerDisposer(
-                new Position(channelCoordinateSpaceCombiner.combined));
-            const positionWidget = controlDisposer.registerDisposer(
-              new PositionWidget(position, channelCoordinateSpaceCombiner, {copyButton: false}));
-            const {trackable} = controlState;
-            controlDisposer.registerDisposer(position.changed.add(() => {
-              const value = position.value;
-              const newChannel = Array.from(value, x => Math.floor(x));
-              const oldParams = trackable.value;
-              if (!arraysEqual(oldParams.channel, newChannel)) {
-                trackable.value = {...trackable.value, channel: newChannel};
-              }
-            }));
-            const updatePosition = () => {
-              const value = position.value;
-              const params = trackable.value;
-              if (!arraysEqual(value, params.channel)) {
-                value.set(params.channel);
-                position.changed.dispatch();
-              }
-            };
-            updatePosition();
-            controlDisposer.registerDisposer(trackable.changed.add(updatePosition));
-            labelDiv.appendChild(positionWidget.element);
-          }
-          const widget = controlDisposer.registerDisposer(new InvlerpWidget(
-              this.visibility, this.display, control, controlState.trackable,
-              this.state.histogramSpecifications, histogramIndex, this.options));
-          element.appendChild(widget.element);
-          ++histogramIndex;
-          break;
-        }
-      }
+    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)));
     }
   }
 
@@ -136,3 +138,44 @@ export class ShaderControls extends Tab {
     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);
+  });
+}

feat: allow key bindings to be associated with layer controls