Bladeren bron

mol-plugin: component/selection managers/UI

David Sehnal 5 jaren geleden
bovenliggende
commit
8b9f59ac5a

+ 5 - 0
src/mol-model/structure/structure/structure.ts

@@ -27,6 +27,7 @@ import { GridLookup3D } from '../../../mol-math/geometry';
 import { UUID } from '../../../mol-util';
 import { CustomProperties } from '../common/custom-property';
 import { AtomicHierarchy } from '../model/properties/atomic';
+import { StructureSelection } from '../query/selection';
 
 class Structure {
     /** Maps unit.id to unit */
@@ -579,6 +580,10 @@ namespace Structure {
         return StructureElement.Loci(structure, elements);
     }
 
+    export function toSubStructureElementLoci(parent: Structure, structure: Structure): StructureElement.Loci {
+        return StructureSelection.toLociWithSourceUnits(StructureSelection.Singletons(parent, structure));
+    }
+
     export function isLoci(x: any): x is Loci {
         return !!x && x.kind === 'structure-loci';
     }

+ 11 - 1
src/mol-plugin-state/helpers/structure-overpaint.ts

@@ -2,6 +2,7 @@
  * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author David Sehnal <david.sehnal@gmail.com>
  */
 
 import { Structure, StructureElement } from '../../mol-model/structure';
@@ -19,7 +20,7 @@ const OverpaintManagerTag = 'overpaint-controls'
 
 export async function setStructureOverpaint(plugin: PluginContext, components: StructureComponentRef[], color: Color | -1, lociGetter: (structure: Structure) => StructureElement.Loci | EmptyLoci, types?: string[], alpha = 1) {
     await eachRepr(plugin, components, (update, repr, overpaintCell) => {
-        if (types && !types.includes(repr.params!.values.type.name)) return
+        if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return
 
         const structure = repr.obj!.data.source.data
         // always use the root structure to get the loci so the overpaint
@@ -45,6 +46,15 @@ export async function setStructureOverpaint(plugin: PluginContext, components: S
     })
 }
 
+export async function clearStructureOverpaint(plugin: PluginContext, components: StructureComponentRef[], types?: string[]) {
+    await eachRepr(plugin, components, (update, repr, overpaintCell) => {
+        if (types && types.length > 0 && !types.includes(repr.params!.values.type.name)) return;
+        if (overpaintCell) {
+            update.delete(overpaintCell.transform.ref);
+        }
+    });
+}
+
 async function eachRepr(plugin: PluginContext, components: StructureComponentRef[], callback: OverpaintEachReprCallback) {
     const state = plugin.state.dataState;    
     const update = state.build();

+ 0 - 21
src/mol-plugin-state/manager/base.ts

@@ -1,21 +0,0 @@
-/**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-// TODO: primites
-
-import { StateObject, State, StateObjectCell, StateBuilder, StateTransformer, StateTransform } from '../../mol-state';
-import { RuntimeContext } from '../../mol-task';
-import { PluginContext } from '../../mol-plugin/context';
-
-export { StateAction, BuilderAction }
-
-type StateAction<P = any, O extends StateObject = StateObject, R = {}> =
-    (cell: StateObjectCell<O>, params: P, ctx: { ctx: RuntimeContext, state: State, plugin: PluginContext }) => Promise<R> | R;
-function StateAction<P = any, O extends StateObject = StateObject, R = {}>(action: StateAction<P, O, R>) { return action; }
-
-type BuilderAction<P = any, O extends StateObject = StateObject, T extends StateTransformer = StateTransformer, R = {}> =
-    (builder: StateBuilder.To<O, T>, params: P, ctx: { options?: Partial<StateTransform.Options>, plugin: PluginContext }) => R;
-function BuilderAction<P = any, O extends StateObject = StateObject, T extends StateTransformer = StateTransformer, R = {}>(action: BuilderAction<P, O, T, R>) { return action; }

+ 22 - 9
src/mol-plugin-state/manager/interactivity.ts

@@ -13,31 +13,44 @@ import { MarkerAction } from '../../mol-util/marker-action';
 import { PluginContext } from '../../mol-plugin/context';
 import { Structure } from '../../mol-model/structure';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
-import { PluginCommands } from '../../mol-plugin/commands';
 import { StructureSelectionManager } from './structure/selection';
+import { PluginComponent } from '../component';
+import { shallowEqual } from '../../mol-util/object';
 
 export { InteractivityManager }
 
-class InteractivityManager {
+interface InteractivityManagerState {
+    props: PD.ValuesFor<InteractivityManager.Params>
+}
+
+class InteractivityManager extends PluginComponent<InteractivityManagerState> {
     readonly lociSelects: InteractivityManager.LociSelectManager;
     readonly lociHighlights: InteractivityManager.LociHighlightManager;
 
     private _props = PD.getDefaultValues(InteractivityManager.Params)
 
-    get props() { return { ...this._props } }
+    readonly events = {
+        propsUpdated: this.ev()
+    };
+
+    get props(): Readonly<InteractivityManagerState['props']> { return { ...this.state.props }; }
+
     setProps(props: Partial<InteractivityManager.Props>) {
-        Object.assign(this._props, props)
-        this.lociSelects.setProps(this._props)
-        this.lociHighlights.setProps(this._props)
+        const old = this.props;
+        const _new = { ...this.state.props, ...props };
+        if (shallowEqual(old, _new)) return;
+
+        this.updateState({ props: _new });
+        this.lociSelects.setProps(_new);
+        this.lociHighlights.setProps(_new);
+        this.events.propsUpdated.next();
     }
 
     constructor(readonly ctx: PluginContext, props: Partial<InteractivityManager.Props> = {}) {
-        Object.assign(this._props, props)
+        super({ props: { ...PD.getDefaultValues(InteractivityManager.Params), ...props } });
 
         this.lociSelects = new InteractivityManager.LociSelectManager(ctx, this._props);
         this.lociHighlights = new InteractivityManager.LociHighlightManager(ctx, this._props);
-
-        PluginCommands.Interactivity.SetProps.subscribe(ctx, e => this.setProps(e.props));
     }
 }
 

+ 134 - 125
src/mol-plugin-state/manager/structure/component.ts

@@ -4,26 +4,26 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
+import { VisualQualityOptions } from '../../../mol-geo/geometry/base';
+import { InteractionsProvider } from '../../../mol-model-props/computed/interactions';
 import { Structure, StructureElement } from '../../../mol-model/structure';
 import { structureAreIntersecting, structureSubtract, structureUnion } from '../../../mol-model/structure/query/utils/structure-set';
+import { setSubtreeVisibility } from '../../../mol-plugin/behavior/static/state';
 import { PluginContext } from '../../../mol-plugin/context';
 import { StateBuilder, StateTransformer } from '../../../mol-state';
 import { Task } from '../../../mol-task';
 import { UUID } from '../../../mol-util';
-import { Color } from '../../../mol-util/color';
+import { arraySetAdd } from '../../../mol-util/array';
 import { ColorNames } from '../../../mol-util/color/names';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { StructureRepresentationProvider } from '../../builder/structure/provider';
-import { StructureComponentParams } from '../../helpers/structure-component';
-import { setStructureOverpaint } from '../../helpers/structure-overpaint';
-import { StructureSelectionQuery, StructureSelectionQueryOptions } from '../../helpers/structure-selection-query';
-import { HierarchyRef, StructureComponentRef, StructureRef, StructureRepresentationRef } from './hierarchy-state';
 import { PluginComponent } from '../../component';
-import { VisualQualityOptions } from '../../../mol-geo/geometry/base';
-import { InteractionsProvider } from '../../../mol-model-props/computed/interactions';
-import { StructureRepresentation3D } from '../../transforms/representation';
-import { arraySetAdd } from '../../../mol-util/array';
+import { StructureComponentParams } from '../../helpers/structure-component';
+import { setStructureOverpaint, clearStructureOverpaint } from '../../helpers/structure-overpaint';
+import { StructureSelectionQuery, StructureSelectionQueryOptions, StructureSelectionQueries } from '../../helpers/structure-selection-query';
 import { CustomStructureProperties } from '../../transforms/model';
+import { StructureRepresentation3D } from '../../transforms/representation';
+import { HierarchyRef, StructureComponentRef, StructureRef, StructureRepresentationRef } from './hierarchy-state';
 
 export { StructureComponentManager };
 
@@ -42,6 +42,10 @@ class StructureComponentManager extends PluginComponent<StructureComponentManage
         return this.plugin.managers.structure.hierarchy.state.currentStructures;
     }
 
+    get pivotStructure(): StructureRef | undefined {
+        return this.currentStructures[0];
+    }
+
     async setOptions(options: StructureComponentManager.Options) {
         const interactionChanged = options.interactions !== this.state.options.interactions;
         this.updateState({ options });
@@ -118,53 +122,113 @@ class StructureComponentManager extends PluginComponent<StructureComponentManage
         return this.clearComponents(structures);
     }
 
-    removeRepresentations(components: StructureComponentRef[], pivot: StructureRepresentationRef) {
+    selectThis(components: ReadonlyArray<StructureComponentRef>) {
+        const mng = this.plugin.managers.structure.selection;
+        mng.clear();
+        for (const c of components) {
+            const loci =  Structure.toSubStructureElementLoci(c.structure.cell.obj!.data, c.cell.obj?.data!)
+            mng.fromLoci('set', loci);
+        }
+    }
+
+    modifyByCurrentSelection(components: ReadonlyArray<StructureComponentRef>, action: 'union' | 'subtract') {
+        return this.plugin.runTask(Task.create('Subtract', async taskCtx => {
+            const b = this.dataState.build();        
+            for (const c of components) {
+                const selection = this.plugin.managers.structure.selection.getStructure(c.structure.cell.obj!.data);
+                if (!selection || selection.elementCount === 0) continue;                
+                this.updateComponent(b, c, selection, action);
+            }
+            await this.dataState.updateTree(b).runInContext(taskCtx);
+        }));
+    }
+
+    toggleVisibility(components: ReadonlyArray<StructureComponentRef>) {
+        if (components.length === 0) return;
+        const isHidden = !components[0].cell.state.isHidden;
+        for (const c of components) {
+            setSubtreeVisibility(this.dataState, c.cell.transform.ref, isHidden);
+        }
+    }
+
+    removeRepresentations(components: ReadonlyArray<StructureComponentRef>, pivot?: StructureRepresentationRef) {
         if (components.length === 0) return;
-        const index = components[0].representations.indexOf(pivot);
-        if (index < 0) return;
 
         const toRemove: HierarchyRef[] = [];
-        for (const c of components) {
-            if (index >= c.representations.length) continue;
-            toRemove.push(c.representations[index]);
+        if (pivot) {
+            const index = components[0].representations.indexOf(pivot);
+            if (index < 0) return;
+
+            for (const c of components) {
+                if (c.representations[index]) toRemove.push(c.representations[index]);
+            }
+        } else {
+            for (const c of components) {
+                for (const r of c.representations) {
+                    toRemove.push(r);
+                }
+            }
         }
+
         return this.plugin.managers.structure.hierarchy.remove(toRemove);
     }
 
-    modify(action: StructureComponentManager.ModifyAction, structures?: ReadonlyArray<StructureRef>) {        
+    async addRepresentation(components: ReadonlyArray<StructureComponentRef>, type: string) {
+        if (components.length === 0) return;
+
+        const { showHydrogens, visualQuality: quality } = this.state.options;
+        const ignoreHydrogens = !showHydrogens;
+        const params = () => ({ ignoreHydrogens, quality });
+
+        for (const component of components) {
+            await this.plugin.builders.structure.representation.addRepresentation(component.cell, {
+                repr: [this.plugin.structureRepresentation.registry.get(type), params]
+            });
+        }
+    }
+
+    async add(params: StructureComponentManager.AddParams, structures?: ReadonlyArray<StructureRef>) {
         return this.plugin.dataTransaction(async () => {
-            if (!structures) structures = this.currentStructures;
-            if (structures.length === 0) return;
-
-            switch (action.kind) {
-                case 'add': await this.modifyAdd(action, structures); break;
-                case 'merge': await this.modifyMerge(action, structures); break;
-                case 'subtract': await this.modifySubtract(action, structures); break;
-                case 'color': await this.modifyColor(action, structures); break;
+            const xs = structures || this.currentStructures;
+            if (xs.length === 0) return;
+
+            const componentKey = UUID.create22();
+            for (const s of xs) {
+                const component = await this.plugin.builders.structure.tryCreateQueryComponent({ 
+                    structure: s.cell,
+                    query: params.selection,
+                    key: componentKey,
+                    label: params.label || (params.selection === StructureSelectionQueries.current ? 'Custom Selection' : ''),
+                });
+                if (params.representation === 'none' || !component) continue;
+                await this.plugin.builders.structure.representation.addRepresentation(component, {
+                    repr: this.plugin.structureRepresentation.registry.get(params.representation)
+                });
             }
         });
     }
 
-    private async modifyAdd(params: StructureComponentManager.ModifyActionAdd, structures: ReadonlyArray<StructureRef>) {
-        const componentKey = UUID.create22();
-        for (const s of structures) {
-            const component = await this.plugin.builders.structure.tryCreateQueryComponent({ 
-                structure: s.cell,
-                query: params.selection,
-                key: componentKey,
-                label: params.label,
-            });
-            if (params.representation === 'none' || !component) continue;
-            await this.plugin.builders.structure.representation.addRepresentation(component, {
-                repr: this.plugin.structureRepresentation.registry.get(params.representation)
-            });
-        }
+    async applyColor(params: StructureComponentManager.ColorParams, structures?: ReadonlyArray<StructureRef>) {
+        return this.plugin.dataTransaction(async () => {
+            const xs = structures || this.currentStructures;
+            if (xs.length === 0) return;
+            const getLoci = (s: Structure) => this.plugin.managers.structure.selection.getLoci(s);
+
+            for (const s of xs) {
+                if (params.action.name === 'reset') {
+                    await clearStructureOverpaint(this.plugin, s.components, params.representations);
+                } else {
+                    const p = params.action.params;
+                    await setStructureOverpaint(this.plugin, s.components, p.color, getLoci, params.representations, p.opacity);
+                }
+            }
+        });
     }
 
-    private updateComponent(builder: StateBuilder.Root, component: StructureComponentRef, by: Structure, action: 'union' | 'subtract', checkIntersecting: boolean) {
+    private updateComponent(builder: StateBuilder.Root, component: StructureComponentRef, by: Structure, action: 'union' | 'subtract') {
         const structure = component.cell.obj?.data;
         if (!structure) return;
-        if (checkIntersecting && !structureAreIntersecting(structure, by)) return;
+        if (action === 'subtract' && !structureAreIntersecting(structure, by)) return;
 
         const parent = component.structure.cell.obj?.data!;
         const modified = action === 'union' ? structureUnion(parent, [structure, by]) : structureSubtract(structure, by);
@@ -182,41 +246,6 @@ class StructureComponentManager extends PluginComponent<StructureComponentManage
         }
     }
 
-    private async modifyMerge(params: StructureComponentManager.ModifyActionMerge, structures: ReadonlyArray<StructureRef>) {
-        return this.plugin.runTask(Task.create('Merge', async taskCtx => {
-            const b = this.dataState.build();        
-            for (const s of structures) {
-                const by = await StructureSelectionQuery.getStructure(this.plugin, taskCtx, params.selection, s.cell.obj?.data!);
-                for (const c of s.components) {
-                    if (params.componentKey !== 'intersecting' && params.componentKey !== c.key) continue;
-                    this.updateComponent(b, c, by, 'union', params.componentKey === 'intersecting' );
-                }
-            }
-            await this.dataState.updateTree(b).runInContext(taskCtx);
-        }));
-    }
-
-    private async modifySubtract(params: StructureComponentManager.ModifyActionSubtract, structures: ReadonlyArray<StructureRef>) {
-        return this.plugin.runTask(Task.create('Subtract', async taskCtx => {
-            const b = this.dataState.build();        
-            for (const s of structures) {
-                const by = await StructureSelectionQuery.getStructure(this.plugin, taskCtx, params.selection, s.cell.obj?.data!);
-                for (const c of s.components) {
-                    if (params.componentKey !== 'intersecting' && params.componentKey !== c.key) continue;
-                    this.updateComponent(b, c, by, 'subtract', true);
-                }
-            }
-            await this.dataState.updateTree(b).runInContext(taskCtx);
-        }));
-    }
-
-    private async modifyColor(params: StructureComponentManager.ModifyActionColor, structures: ReadonlyArray<StructureRef>) {
-        const getLoci = (s: Structure) => this.plugin.managers.structure.selection.getLoci(s);
-        for (const s of structures) {
-            await setStructureOverpaint(this.plugin, s.components, params.action.name === 'color' ? params.action.params : -1, getLoci);
-        }
-    }
-
     private get dataState() {
         return this.plugin.state.dataState;
     }
@@ -247,63 +276,43 @@ namespace StructureComponentManager {
         interactions: PD.Group(InteractionsProvider.defaultParams, { label: 'Non-covalent Interactions' }),
     }
     export type Options = PD.Values<typeof OptionsParams>
-
-    export type ActionType = 'add' | 'merge' | 'subtract' | 'color'
-
+    
     const SelectionParam = PD.Select(StructureSelectionQueryOptions[1][0], StructureSelectionQueryOptions)
+    
+    export function getAddParams(plugin: PluginContext) {
+        return {
+            selection: SelectionParam,
+            representation: getRepresentationTypesSelect(plugin, plugin.managers.structure.hierarchy.state.currentStructures[0], [['none', '< None >']]),
+            label: PD.Text('')
+        };
+    }
+    export type AddParams = { selection: StructureSelectionQuery, label: string, representation: string }
+
+    export function getColorParams(plugin: PluginContext, pivot: StructureRef | StructureComponentRef | undefined) {
+        return {
+            action: PD.MappedStatic('color', {
+                color: PD.Group({
+                    color: PD.Color(ColorNames.blue, { isExpanded: true }),
+                    opacity: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }),
+                }, { isFlat: true }),
+                reset: PD.EmptyGroup()
+            }),
+            representations: PD.MultiSelect([], getRepresentationTypes(plugin, pivot), { emptyValue: 'All' })
+        };
+    }
+    export type ColorParams = PD.Values<ReturnType<typeof getColorParams>>
 
-    function getComponentsOptions(plugin: PluginContext, custom: [string, string][], label?: string) {
-        const types = [
-            ...custom,
-            ...plugin.managers.structure.hierarchy.componentGroups.map(g => [g[0].key!, g[0].cell.obj?.label])
-        ] as [string, string][];
-        return PD.Select(types[0][0], types, { label });
+    export function getRepresentationTypes(plugin: PluginContext, pivot: StructureRef | StructureComponentRef | undefined) {
+        return pivot?.cell.obj?.data
+            ? plugin.structureRepresentation.registry.getApplicableTypes(pivot.cell.obj?.data!)
+            : plugin.structureRepresentation.registry.types;
     }
 
-    function getRepresentationTypes(plugin: PluginContext, pivot: StructureRef | undefined, custom: [string, string][], label?: string) {
+    function getRepresentationTypesSelect(plugin: PluginContext, pivot: StructureRef | undefined, custom: [string, string][], label?: string) {
         const types = [
             ...custom,
-            ...(pivot?.cell.obj?.data
-                ? plugin.structureRepresentation.registry.getApplicableTypes(pivot.cell.obj?.data!)
-                : plugin.structureRepresentation.registry.types)
+            ...getRepresentationTypes(plugin, pivot)
         ] as [string, string][];
         return PD.Select(types[0][0], types, { label });
     }
-
-    export function getActionParams(plugin: PluginContext, action: ActionType) {
-        switch (action) {
-            case 'add': 
-                return {
-                    kind: PD.Value<ActionType>(action, { isHidden: true }),
-                    selection: SelectionParam,
-                    label: PD.Text(''),
-                    representation: getRepresentationTypes(plugin, plugin.managers.structure.hierarchy.state.currentStructures[0], [['none', '< None >']])
-                };
-            case 'merge':
-            case 'subtract':
-                return {
-                    kind: PD.Value<ActionType>(action, { isHidden: true }),
-                    selection: SelectionParam,
-                    componentKey: getComponentsOptions(plugin, [['intersecting', '< Intersecting >']], 'Target')
-                };
-            case 'color':
-                // TODO: ability to reset
-                return {
-                    kind: PD.Value<ActionType>(action, { isHidden: true }),
-                    action: PD.MappedStatic('color', {
-                        color: PD.Color(ColorNames.blue, { label: 'Color', isExpanded: true }),
-                        reset: PD.EmptyGroup()
-                    }),
-                    // TODO: filter by representation type
-                    // representation: getRepresentationTypes(plugin, void 0, [['all', '< All >']])
-                };
-        }
-    }
-
-    export type ModifyActionAdd = { kind: 'add', selection: StructureSelectionQuery, label: string, representation: string }
-    export type ModifyActionMerge = { kind: 'merge', selection: StructureSelectionQuery, componentKey: 'intersecting' | string }
-    export type ModifyActionSubtract = { kind: 'subtract', selection: StructureSelectionQuery, componentKey: 'intersecting' | string }
-    export type ModifyActionColor = { kind: 'color', action: { name: 'color', params: Color } | { name: 'reset', params: any } } //, representationType?: string }
-    
-    export type ModifyAction = ModifyActionAdd | ModifyActionMerge | ModifyActionSubtract | ModifyActionColor
 }

+ 0 - 7
src/mol-plugin-state/manager/structure/representations.ts

@@ -1,7 +0,0 @@
-/**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-// TODO: manager that handles representation structures

+ 17 - 13
src/mol-plugin-state/manager/structure/selection.ts

@@ -5,21 +5,21 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { PluginComponent } from '../../component';
-import { PluginContext } from '../../../mol-plugin/context';
-import { StructureElement, Structure } from '../../../mol-model/structure';
+import { OrderedSet } from '../../../mol-data/int';
+import { BoundaryHelper } from '../../../mol-math/geometry/boundary-helper';
 import { Vec3 } from '../../../mol-math/linear-algebra';
-import { Boundary } from '../../../mol-model/structure/structure/util/boundary';
 import { PrincipalAxes } from '../../../mol-math/linear-algebra/matrix/principal-axes';
+import { EmptyLoci, Loci } from '../../../mol-model/loci';
+import { Structure, StructureElement } from '../../../mol-model/structure';
+import { Boundary } from '../../../mol-model/structure/structure/util/boundary';
+import { PluginContext } from '../../../mol-plugin/context';
+import { StateObject } from '../../../mol-state';
+import { Task } from '../../../mol-task';
 import { structureElementStatsLabel } from '../../../mol-theme/label';
-import { OrderedSet } from '../../../mol-data/int';
 import { arrayRemoveAtInPlace } from '../../../mol-util/array';
-import { EmptyLoci, Loci } from '../../../mol-model/loci';
-import { StateObject, StateSelection } from '../../../mol-state';
-import { PluginStateObject } from '../../objects';
+import { PluginComponent } from '../../component';
 import { StructureSelectionQuery } from '../../helpers/structure-selection-query';
-import { Task } from '../../../mol-task';
-import { BoundaryHelper } from '../../../mol-math/geometry/boundary-helper';
+import { PluginStateObject } from '../../objects';
 
 interface StructureSelectionManagerState {
     entries: Map<string, SelectionEntry>,
@@ -339,8 +339,9 @@ export class StructureSelectionManager extends PluginComponent<StructureSelectio
     }
 
     private get applicableStructures() {
-        // TODO: use "current structures" once implemented
-        return this.plugin.state.dataState.select(StateSelection.Generators.rootsOfType(PluginStateObject.Molecule.Structure)).map(s => s.obj!.data)
+        return this.plugin.managers.structure.hierarchy.state.currentStructures
+            .filter(s => !!s.cell.obj)
+            .map(s => s.cell.obj!.data);
     }
 
     private triggerInteraction(modifier: StructureSelectionModifier, loci: Loci, applyGranularity = true) {
@@ -357,9 +358,12 @@ export class StructureSelectionManager extends PluginComponent<StructureSelectio
         }
     }
 
+    fromLoci(modifier: StructureSelectionModifier, loci: Loci, applyGranularity = true) {
+        this.triggerInteraction(modifier, loci, applyGranularity);
+    }
+
     fromSelectionQuery(modifier: StructureSelectionModifier, selectionQuery: StructureSelectionQuery, applyGranularity = true) {
         this.plugin.runTask(Task.create('Structure Selection', async runtime => {
-            // const loci: Loci[] = [];
             for (const s of this.applicableStructures) {
                 const loci = await StructureSelectionQuery.getLoci(this.plugin, runtime, selectionQuery, s);
                 this.triggerInteraction(modifier, loci, applyGranularity);

+ 3 - 1
src/mol-plugin-ui/controls/action-menu.tsx

@@ -41,13 +41,15 @@ export namespace ActionMenu {
         return { label, value: iconOrValue };
     }
 
-    export function createItems<T>(xs: ArrayLike<T>, options?: { label?: (t: T) => string, value?: (t: T) => any, category?: (t: T) => string | undefined }) {
+    export function createItems<T>(xs: ArrayLike<T>, options?: { filter?: (t: T) => boolean, label?: (t: T) => string, value?: (t: T) => any, category?: (t: T) => string | undefined }) {
         const { label, value, category } = options || { };
         let cats: Map<string, (ActionMenu.Item | string)[]> | undefined = void 0;
         const items: (ActionMenu.Item | (ActionMenu.Item | string)[] | string)[] = [];
         for (let i = 0; i < xs.length; i++) {
             const x = xs[i];
 
+            if (options?.filter && !options.filter(x)) continue;
+
             const catName = category?.(x);
             const l = label ? label(x) : '' + x;
             const v = value ? value(x) : x;

+ 2 - 1
src/mol-plugin-ui/controls/parameters.tsx

@@ -726,13 +726,14 @@ export class MultiSelectControl extends React.PureComponent<ParamProps<PD.MultiS
 
     render() {
         const current = this.props.value;
+        const emptyLabel = this.props.param.emptyValue;
         const label = this.props.param.label || camelCaseToWords(this.props.name);
         return <>
             <div className='msp-control-row'>
                 <span>{label}</span>
                 <div>
                     <button onClick={this.toggleExpanded}>
-                        {`${current.length} of ${this.props.param.options.length}`}
+                        {current.length === 0 && emptyLabel ? emptyLabel : `${current.length} of ${this.props.param.options.length}`}
                     </button>
                 </div>
             </div>

+ 0 - 1
src/mol-plugin-ui/left-panel.tsx

@@ -131,7 +131,6 @@ class FullSettings extends PluginUIComponent {
     componentDidMount() {
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
-        this.subscribe(this.plugin.events.interactivity.propsUpdated, () => this.forceUpdate());
     }
 
     icon(name: IconName, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title: string, isOn = true) {

+ 106 - 89
src/mol-plugin-ui/structure/components.tsx

@@ -5,19 +5,18 @@
  */
 
 import * as React from 'react';
-import { CollapsableControls, CollapsableState, PurePluginUIComponent } from '../base';
-import { StructureComponentRef, StructureRepresentationRef } from '../../mol-plugin-state/manager/structure/hierarchy-state';
-import { State, StateAction } from '../../mol-state';
+import { StructureComponentManager } from '../../mol-plugin-state/manager/structure/component';
+import { StructureComponentRef, StructureRepresentationRef, StructureRef } from '../../mol-plugin-state/manager/structure/hierarchy-state';
 import { PluginCommands } from '../../mol-plugin/commands';
-import { ExpandGroup, IconButton, ControlGroup, ToggleButton } from '../controls/common';
-import { UpdateTransformControl } from '../state/update-transform';
+import { State } from '../../mol-state';
+import { ParamDefinition } from '../../mol-util/param-definition';
+import { CollapsableControls, CollapsableState, PurePluginUIComponent } from '../base';
 import { ActionMenu } from '../controls/action-menu';
-import { ApplyActionControl } from '../state/apply-action';
-import { StateTransforms } from '../../mol-plugin-state/transforms';
+import { ExpandGroup, IconButton, ToggleButton } from '../controls/common';
 import { Icon } from '../controls/icons';
-import { StructureComponentManager } from '../../mol-plugin-state/manager/structure/component';
-import { ParamDefinition } from '../../mol-util/param-definition';
 import { ParameterControls } from '../controls/parameters';
+import { UpdateTransformControl } from '../state/update-transform';
+import { PluginContext } from '../../mol-plugin/context';
 
 interface StructureComponentControlState extends CollapsableState {
     isDisabled: boolean
@@ -43,23 +42,28 @@ export class StructureComponentControls extends CollapsableControls<{}, Structur
 }
 
 interface ComponentEditorControlsState {
-    action?: 'preset' | 'modify' | 'options',
-    isDisabled: boolean
+    action?: 'preset' | 'add' | 'options',
+    isEmpty: boolean,
+    isBusy: boolean
 }
 
 class ComponentEditorControls extends PurePluginUIComponent<{}, ComponentEditorControlsState> {
     state: ComponentEditorControlsState = {
-        isDisabled: false
+        isEmpty: true,
+        isBusy: false
     };
 
-    get current() {
-        return this.plugin.managers.structure.hierarchy.behaviors.current;
+    get isDisabled() {
+        return this.state.isBusy || this.state.isEmpty
     }
 
     componentDidMount() {
-        this.subscribe(this.current, () => this.setState({ action: this.state.action !== 'options' ? void 0 : 'options' }));
+        this.subscribe(this.plugin.managers.structure.hierarchy.behaviors.current, c => this.setState({
+            action: this.state.action !== 'options' || c.structures.length === 0 ? void 0 : 'options',
+            isEmpty: c.structures.length === 0
+        }));
         this.subscribe(this.plugin.behaviors.state.isBusy, v => {
-            this.setState({ isDisabled: v, action: this.state.action !== 'options' ? void 0 : 'options' })
+            this.setState({ isBusy: v, action: this.state.action !== 'options' ? void 0 : 'options' })
         });
     }
 
@@ -68,7 +72,7 @@ class ComponentEditorControls extends PurePluginUIComponent<{}, ComponentEditorC
     }
 
     togglePreset = this.toggleAction('preset');
-    toggleModify = this.toggleAction('modify');
+    toggleAdd = this.toggleAction('add');
     toggleOptions = this.toggleAction('options');
     hideAction = () => this.setState({ action: void 0 });
 
@@ -80,7 +84,7 @@ class ComponentEditorControls extends PurePluginUIComponent<{}, ComponentEditorC
         const actions = [
             ActionMenu.Item('Clear', null),
         ];
-        // TODO: filter by applicable
+        // TODO: filter by applicable??
         for (const p of this.plugin.builders.structure.representation.providerList) {
             actions.push(ActionMenu.Item(p.display.name, p));
         }
@@ -98,75 +102,60 @@ class ComponentEditorControls extends PurePluginUIComponent<{}, ComponentEditorC
         else mng.component.applyPreset(structures, item.value as any);
     }
 
-    modifyComponentControls = <div className='msp-control-offset'><ModifyComponentControls onApply={this.hideAction} /></div>
-
     render() {
         return <>
             <div className='msp-control-row msp-select-row'>
-                <ToggleButton icon='bookmarks' label='Preset' toggle={this.togglePreset} isSelected={this.state.action === 'preset'} disabled={this.state.isDisabled} />
-                <ToggleButton icon='flow-cascade' label='Modify' toggle={this.toggleModify} isSelected={this.state.action === 'modify'} disabled={this.state.isDisabled} />
-                <ToggleButton icon='cog' label='Options' toggle={this.toggleOptions} isSelected={this.state.action === 'options'} disabled={this.state.isDisabled} />
+                <ToggleButton icon='bookmarks' label='Preset' toggle={this.togglePreset} isSelected={this.state.action === 'preset'} disabled={this.isDisabled} />
+                <ToggleButton icon='plus' label='Add' toggle={this.toggleAdd} isSelected={this.state.action === 'add'} disabled={this.isDisabled} />
+                <ToggleButton icon='cog' label='Options' toggle={this.toggleOptions} isSelected={this.state.action === 'options'} disabled={this.isDisabled} />
             </div>
             {this.state.action === 'preset' && this.presetControls}
-            {this.state.action === 'modify' && this.modifyComponentControls}
-            {this.state.action === 'options' && <div className='msp-control-offset'><ComponentOptionsControls isDisabled={this.state.isDisabled} /></div>}
+            {this.state.action === 'add' && <div className='msp-control-offset'>
+                <AddComponentControls structures={this.plugin.managers.structure.component.currentStructures} onApply={this.hideAction} />
+            </div>}
+            {this.state.action === 'options' && <div className='msp-control-offset'><ComponentOptionsControls isDisabled={this.isDisabled} /></div>}
         </>;
     }
 }
 
-interface ModifyComponentControlsState {
-    action?: StructureComponentManager.ActionType,
-    actionParams?: ParamDefinition.Params,
-    actionParamValues?: StructureComponentManager.ModifyAction
+interface AddComponentControlsState {
+    plugin: PluginContext,
+    structures: ReadonlyArray<StructureRef>,
+    params: ParamDefinition.Params,
+    values: StructureComponentManager.AddParams
 }
 
-class ModifyComponentControls extends PurePluginUIComponent<{ onApply: () => void }, ModifyComponentControlsState> {
-    state: ModifyComponentControlsState = { };
-
-    private toggleAction(action: StructureComponentManager.ActionType) {
-        return () => {
-            if (this.state.action === action) {
-                this.setState({ action: void 0, actionParams: void 0, actionParamValues: void 0 });
-            } else {
-                const actionParams = StructureComponentManager.getActionParams(this.plugin, action) as any;
-                const actionParamValues = ParamDefinition.getDefaultValues(actionParams) as StructureComponentManager.ModifyAction;
-                this.setState({ action, actionParams, actionParamValues });
-            }
-        }
-    }
+interface AddComponentControlsProps {
+    structures: ReadonlyArray<StructureRef>,
+    onApply: () => void
+}
 
-    toggleAdd = this.toggleAction('add');
-    toggleMerge = this.toggleAction('merge');
-    toggleSubtract = this.toggleAction('subtract');
-    toggleColor = this.toggleAction('color');
+class AddComponentControls extends PurePluginUIComponent<AddComponentControlsProps, AddComponentControlsState> {
+    static createState(plugin: PluginContext, structures: ReadonlyArray<StructureRef>): AddComponentControlsState {
+        const params = StructureComponentManager.getAddParams(plugin);
+        return { plugin, structures, params, values: ParamDefinition.getDefaultValues(params) };
+    }
 
-    hideAction = () => this.setState({ action: void 0 });
+    state = AddComponentControls.createState(this.plugin, this.props.structures);
 
     apply = () => {
-        this.plugin.managers.structure.component.modify(this.state.actionParamValues!);
+        this.plugin.managers.structure.component.add(this.state.values, this.state.structures);
         this.props.onApply();
     }
 
-    paramsChanged = (actionParamValues: any) => this.setState({ actionParamValues })
-    get paramControls() {
-        if (!this.state.action) return null;
-        return <>
-            <ParameterControls params={this.state.actionParams!} values={this.state.actionParamValues!} onChangeObject={this.paramsChanged} />
-            <button className={`msp-btn msp-btn-block msp-btn-commit msp-btn-commit-on`} onClick={this.apply} style={{ marginTop: '1px' }}>
-                <Icon name='ok' /> Apply
-            </button>
-        </>
+    paramsChanged = (values: any) => this.setState({ values })
+
+    static getDerivedStateFromProps(props: AddComponentControlsProps, state: AddComponentControlsState) {
+        if (props.structures === state.structures) return null;
+        return AddComponentControls.createState(state.plugin, props.structures)
     }
 
     render() {
         return <>
-            <div className='msp-control-row msp-select-row'>
-                <ToggleButton icon='plus' label='New' toggle={this.toggleAdd} isSelected={this.state.action === 'add'} />
-                <ToggleButton icon='flow-branch' label='Union' toggle={this.toggleMerge} isSelected={this.state.action === 'merge'} />
-                <ToggleButton icon='minus' label='Sub' toggle={this.toggleSubtract} isSelected={this.state.action === 'subtract'} />
-                <ToggleButton icon='brush' label='Color' toggle={this.toggleColor} isSelected={this.state.action === 'color'} />
-            </div>
-            {this.paramControls}
+            <ParameterControls params={this.state.params} values={this.state.values} onChangeObject={this.paramsChanged} />
+            <button className={`msp-btn msp-btn-block msp-btn-commit msp-btn-commit-on`} onClick={this.apply} style={{ marginTop: '1px' }}>
+                <Icon name='plus' /> Create Selection
+            </button>
         </>;
     }
 }
@@ -200,11 +189,10 @@ class ComponentListControls extends PurePluginUIComponent {
     }
 }
 
-type StructureComponentEntryActions = 'add-repr' | 'remove' | 'none'
+type StructureComponentEntryActions = 'action' | 'remove'
 
-const createRepr = StateAction.fromTransformer(StateTransforms.Representation.StructureRepresentation3D);
-class StructureComponentGroup extends PurePluginUIComponent<{ group: StructureComponentRef[] }, { action: StructureComponentEntryActions }> {
-    state = { action: 'none' as StructureComponentEntryActions }
+class StructureComponentGroup extends PurePluginUIComponent<{ group: StructureComponentRef[] }, { action?: StructureComponentEntryActions }> {
+    state = { action: void 0 as StructureComponentEntryActions | undefined }
 
     get pivot() {
         return this.props.group[0];
@@ -218,30 +206,54 @@ class StructureComponentGroup extends PurePluginUIComponent<{ group: StructureCo
 
     toggleVisible = (e: React.MouseEvent<HTMLElement>) => {
         e.preventDefault();
-        // TODO: check visibility beforehand to set correct value if user made individual change
-        for (const c of this.props.group) {
-            PluginCommands.State.ToggleVisibility(this.plugin, { state: c.cell.parent, ref: c.cell.transform.ref });
-        }
         e.currentTarget.blur();
+        this.plugin.managers.structure.component.toggleVisibility(this.props.group);
+    }
+
+    get actions(): ActionMenu.Items {
+        const mng = this.plugin.managers.structure.component;
+        const ret = [
+            [
+                'Add Representation',
+                ...StructureComponentManager.getRepresentationTypes(this.plugin, this.props.group[0])
+                    .map(t => ActionMenu.Item(t[1], () => mng.addRepresentation(this.props.group, t[0])))
+            ],
+            ActionMenu.Item('Select This', 'flash', () => mng.selectThis(this.props.group)),
+            ActionMenu.Item('Include Current Selection', 'plus', () => mng.modifyByCurrentSelection(this.props.group, 'union')),
+            ActionMenu.Item('Subtract Current Selection', 'minus', () => mng.modifyByCurrentSelection(this.props.group, 'subtract'))
+        ];
+        return ret;
     }
 
     get removeActions(): ActionMenu.Items {
         const ret = [
-            ActionMenu.Item('Remove Selection', 'remove', () => this.plugin.managers.structure.hierarchy.remove(this.props.group))
+            ActionMenu.Item('Remove', 'remove', () => this.plugin.managers.structure.hierarchy.remove(this.props.group))
         ];
-        for (const repr of this.pivot.representations) {
-            ret.push(ActionMenu.Item(`Remove ${repr.cell.obj?.label}`, 'remove', () => this.plugin.managers.structure.component.removeRepresentations(this.props.group, repr)))
+
+        const reprs = this.pivot.representations;
+        if (reprs.length === 0) {
+            return ret;
         }
+        
+        ret.push(ActionMenu.Item(`Remove Representation${reprs.length > 1 ? 's' : ''}`, 'remove', () => this.plugin.managers.structure.component.removeRepresentations(this.props.group)));
+        
         return ret;
     }
+
+    selectAction: ActionMenu.OnSelect = item => {
+        if (!item) return;
+        this.setState({ action: void 0 });
+        (item?.value as any)();
+    }
     
     selectRemoveAction: ActionMenu.OnSelect = item => {
         if (!item) return;
+        this.setState({ action: void 0 });
         (item?.value as any)();
     }
     
-    toggleAddRepr = () => this.setState({ action: this.state.action === 'none' ? 'add-repr' : 'none' });
-    toggleRemoveActions = () => this.setState({ action: this.state.action === 'none' ? 'remove' : 'none' });
+    toggleAction = () => this.setState({ action: this.state.action === 'action' ? void 0 : 'action' });
+    toggleRemove = () => this.setState({ action: this.state.action === 'remove' ? void 0 : 'remove' });
 
     highlight = (e: React.MouseEvent<HTMLElement>) => {
         e.preventDefault();
@@ -277,27 +289,32 @@ class StructureComponentGroup extends PurePluginUIComponent<{ group: StructureCo
                 </button>
                 <div className='msp-select-row'>
                     <IconButton onClick={this.toggleVisible} icon='visual-visibility' toggleState={!cell.state.isHidden} title={`${cell.state.isHidden ? 'Show' : 'Hide'} component`} small />
-                    <IconButton onClick={this.toggleRemoveActions} icon='remove' title='Remove' small toggleState={this.state.action === 'remove'} />
-                    <IconButton onClick={this.toggleAddRepr} icon='plus' title='Add Representation' toggleState={this.state.action === 'add-repr'} />
+                    <IconButton onClick={this.toggleRemove} icon='remove' title='Remove' small toggleState={this.state.action === 'remove'} />
+                    <IconButton onClick={this.toggleAction} icon='dot-3' title='Actions' toggleState={this.state.action === 'action'} />
                 </div>
             </div>
             {this.state.action === 'remove' && <ActionMenu items={this.removeActions} onSelect={this.selectRemoveAction} />}
+            {this.state.action === 'action' && <ActionMenu items={this.actions} onSelect={this.selectAction} />}
             <div className='msp-control-offset'>
-                {this.state.action === 'add-repr' && 
-                <ControlGroup header='Add Representation' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleAddRepr} topRightIcon='off'>
-                    <ApplyActionControl plugin={this.plugin} state={cell.parent} action={createRepr} nodeRef={component.cell.transform.ref} hideHeader noMargin onApply={this.toggleAddRepr} applyLabel='Add' />
-                </ControlGroup>}
-                {component.representations.map(r => <StructureRepresentationEntry key={r.cell.transform.ref} representation={r} />)}
+                {component.representations.map(r => <StructureRepresentationEntry group={this.props.group} key={r.cell.transform.ref} representation={r} />)}
             </div>
         </>;
     }
 }
 
-class StructureRepresentationEntry extends PurePluginUIComponent<{ representation: StructureRepresentationRef }> {
+class StructureRepresentationEntry extends PurePluginUIComponent<{ group: StructureComponentRef[], representation: StructureRepresentationRef }> {
+    remove = () => this.plugin.managers.structure.component.removeRepresentations(this.props.group, this.props.representation);
+
     render() {
         const repr = this.props.representation.cell;
-        return <ExpandGroup header={`${repr.obj?.label || ''} Representation`} noOffset>
-            <UpdateTransformControl state={repr.parent} transform={repr.transform} customHeader='none' noMargin />
-        </ExpandGroup>;
+        // TODO: style in CSS
+        return <div style={{ position: 'relative' }}>
+            <ExpandGroup header={`${repr.obj?.label || ''} Representation`} noOffset>
+                <UpdateTransformControl state={repr.parent} transform={repr.transform} customHeader='none' noMargin />
+                <IconButton onClick={this.remove} icon='remove' title='Remove' small style={{ 
+                    position: 'absolute', top: 0, right: 0, lineHeight: '20px', height: '20px', textAlign: 'right', width: '44px', paddingRight: '6px'
+                }} />
+            </ExpandGroup>
+        </div>;
     }
 }

+ 80 - 32
src/mol-plugin-ui/structure/selection.tsx

@@ -6,20 +6,23 @@
  */
 
 import * as React from 'react';
-import { CollapsableControls, CollapsableState } from '../base';
-import { StructureSelectionQuery, StructureSelectionQueryList } from '../../mol-plugin-state/helpers/structure-selection-query';
-import { PluginCommands } from '../../mol-plugin/commands';
-import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { StructureElement } from '../../mol-model/structure';
+import { StructureSelectionQueries, StructureSelectionQuery, StructureSelectionQueryList } from '../../mol-plugin-state/helpers/structure-selection-query';
 import { InteractivityManager } from '../../mol-plugin-state/manager/interactivity';
-import { ParameterControls } from '../controls/parameters';
+import { StructureComponentManager } from '../../mol-plugin-state/manager/structure/component';
+import { StructureRef } from '../../mol-plugin-state/manager/structure/hierarchy-state';
+import { StructureSelectionModifier } from '../../mol-plugin-state/manager/structure/selection';
+import { memoize1 } from '../../mol-util/memoize';
+import { ParamDefinition } from '../../mol-util/param-definition';
 import { stripTags } from '../../mol-util/string';
-import { StructureElement } from '../../mol-model/structure';
+import { CollapsableControls, CollapsableState, PurePluginUIComponent } from '../base';
 import { ActionMenu } from '../controls/action-menu';
-import { ToggleButton, ExpandGroup } from '../controls/common';
+import { ExpandGroup, ToggleButton } from '../controls/common';
 import { Icon } from '../controls/icons';
-import { StructureSelectionModifier } from '../../mol-plugin-state/manager/structure/selection';
+import { ParameterControls } from '../controls/parameters';
 
 export const DefaultQueries = ActionMenu.createItems(StructureSelectionQueryList, {
+    filter: q => q !== StructureSelectionQueries.current,
     label: q => q.label,
     category: q => q.category
 });
@@ -33,9 +36,10 @@ interface StructureSelectionControlsState extends CollapsableState {
     extraRadius: number,
     durationMs: number,
 
-    isDisabled: boolean,
+    isEmpty: boolean,
+    isBusy: boolean,
 
-    queryAction?: StructureSelectionModifier
+    action?: StructureSelectionModifier | 'color'
 }
 
 export class StructureSelectionControls<P, S extends StructureSelectionControlsState> extends CollapsableControls<P, S> {
@@ -44,15 +48,26 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
             this.forceUpdate()
         });
 
-        this.subscribe(this.plugin.events.interactivity.propsUpdated, () => {
+        this.subscribe(this.plugin.managers.interactivity.events.propsUpdated, () => {
             this.forceUpdate()
         });
 
+        this.subscribe(this.plugin.managers.structure.hierarchy.behaviors.current, c => {
+            const isEmpty = c.structures.length === 0;
+            if (this.state.isEmpty !== isEmpty) {
+                this.setState({ isEmpty });
+            }
+        });
+
         this.subscribe(this.plugin.behaviors.state.isBusy, v => {
-            this.setState({ isDisabled: v, queryAction: void 0 })
+            this.setState({ isBusy: v, action: void 0 })
         })
     }
 
+    get isDisabled() {
+        return this.state.isBusy || this.state.isEmpty
+    }
+
     get stats() {
         const stats = this.plugin.managers.structure.selection.stats
         if (stats.structureCount === 0 || stats.elementCount === 0) {
@@ -82,10 +97,8 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
         }
     }
 
-    setProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
-        if (p.name === 'granularity') {
-            PluginCommands.Interactivity.SetProps(this.plugin, { props: { granularity: p.value } });
-        }
+    setProps = (props: any) => {
+        this.plugin.managers.interactivity.setProps(props);
     }
 
     get values () {
@@ -99,34 +112,37 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
     }
 
     selectQuery: ActionMenu.OnSelect = item => {
-        if (!item || !this.state.queryAction) {
-            this.setState({ queryAction: void 0 });
+        if (!item || !this.state.action) {
+            this.setState({ action: void 0 });
             return;
         }
-        const q = this.state.queryAction!;
-        this.setState({ queryAction: void 0 }, () => {
+        const q = this.state.action! as StructureSelectionModifier;
+        this.setState({ action: void 0 }, () => {
             this.set(q, item.value as StructureSelectionQuery);
         })
     }
 
     queries = DefaultQueries
 
-    private showQueries(q: StructureSelectionModifier) {
-        return () => this.setState({ queryAction: this.state.queryAction === q ? void 0 : q });
+    private showAction(q: StructureSelectionControlsState['action']) {
+        return () => this.setState({ action: this.state.action === q ? void 0 : q });
     }
 
-    toggleAdd = this.showQueries('add')
-    toggleRemove = this.showQueries('remove')
-    toggleOnly = this.showQueries('set')
+    toggleAdd = this.showAction('add')
+    toggleRemove = this.showAction('remove')
+    toggleOnly = this.showAction('set')
+    toggleColor = this.showAction('color')
 
     get controls() {
         return <div>
             <div className='msp-control-row msp-select-row'>
-                <ToggleButton icon='plus' label='Add' toggle={this.toggleAdd} isSelected={this.state.queryAction === 'add'} disabled={this.state.isDisabled} />
-                <ToggleButton icon='minus' label='Remove' toggle={this.toggleRemove} isSelected={this.state.queryAction === 'remove'} disabled={this.state.isDisabled} />
-                <ToggleButton icon='flash' label='Set' toggle={this.toggleOnly} isSelected={this.state.queryAction === 'set'} disabled={this.state.isDisabled} />
+                <ToggleButton icon='plus' label='Add' toggle={this.toggleAdd} isSelected={this.state.action === 'add'} disabled={this.isDisabled} />
+                <ToggleButton icon='minus' label='Rem' toggle={this.toggleRemove} isSelected={this.state.action === 'remove'} disabled={this.isDisabled} />
+                <ToggleButton icon='flash' label='Set' toggle={this.toggleOnly} isSelected={this.state.action === 'set'} disabled={this.isDisabled} />
+                <ToggleButton icon='brush' label='Color' toggle={this.toggleColor} isSelected={this.state.action === 'color'} disabled={this.isDisabled} />
             </div>
-            {this.state.queryAction && <ActionMenu items={this.queries} onSelect={this.selectQuery} />}
+            {(this.state.action && this.state.action !== 'color') && <ActionMenu items={this.queries} onSelect={this.selectQuery} />}
+            {this.state.action === 'color' && <div className='msp-control-offset'><ApplyColorControls /></div>}
         </div>
     }
 
@@ -139,9 +155,10 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
             extraRadius: 4,
             durationMs: 250,
 
-            queryAction: void 0,
+            action: void 0,
 
-            isDisabled: false
+            isEmpty: true,
+            isBusy: false
         } as S
     }
 
@@ -166,8 +183,8 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
         }
 
         return <>
+            <ParameterControls params={StructureSelectionParams} values={this.values} onChangeObject={this.setProps} />
             {this.controls}
-            <ParameterControls params={StructureSelectionParams} values={this.values} onChange={this.setProps} isDisabled={this.state.isDisabled} />
             <div className='msp-control-row msp-row-text' style={{ marginTop: '6px' }}>
                 <button className='msp-btn msp-btn-block' onClick={this.focus}>
                     <Icon name='focus-on-visual' style={{ position: 'absolute', left: '5px' }} />
@@ -181,4 +198,35 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
             </ExpandGroup>}
         </>
     }
+}
+
+interface ApplyColorControlsState {
+    values: StructureComponentManager.ColorParams
+}
+
+interface ApplyColorControlsProps {
+    onApply?: () => void
+}
+
+class ApplyColorControls extends PurePluginUIComponent<ApplyColorControlsProps, ApplyColorControlsState> {
+    _params = memoize1((pivot: StructureRef | undefined) => StructureComponentManager.getColorParams(this.plugin, pivot));
+    get params() { return this._params(this.plugin.managers.structure.component.pivotStructure); }
+
+    state = { values: ParamDefinition.getDefaultValues(this.params) };
+
+    apply = () => {
+        this.plugin.managers.structure.component.applyColor(this.state.values);
+        this.props.onApply?.();
+    }
+
+    paramsChanged = (values: any) => this.setState({ values })
+
+    render() {
+        return <>
+            <ParameterControls params={this.params} values={this.state.values} onChangeObject={this.paramsChanged} />
+            <button className={`msp-btn msp-btn-block msp-btn-commit msp-btn-commit-on`} onClick={this.apply} style={{ marginTop: '1px' }}>
+                <Icon name='check' /> Apply Coloring
+            </button>
+        </>;
+    }
 }

+ 0 - 5
src/mol-plugin-ui/viewport.tsx

@@ -61,10 +61,6 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
         PluginCommands.Layout.Update(this.plugin, { state: { [p.name]: p.value } });
     }
 
-    setInteractivityProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
-        PluginCommands.Interactivity.SetProps(this.plugin, { props: { [p.name]: p.value } });
-    }
-
     screenshot = () => {
         this.plugin.helpers.viewportScreenshot?.download();
     }
@@ -72,7 +68,6 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
     componentDidMount() {
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
-        this.subscribe(this.plugin.events.interactivity.propsUpdated, () => this.forceUpdate());
     }
 
     icon(name: IconName, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title: string, isOn = true) {

+ 0 - 8
src/mol-plugin/behavior/static/misc.ts

@@ -10,7 +10,6 @@ import { PluginCommands } from '../../commands';
 
 export function registerDefault(ctx: PluginContext) {
     Canvas3DSetSettings(ctx);
-    InteractivitySetProps(ctx);
 }
 
 export function Canvas3DSetSettings(ctx: PluginContext) {
@@ -19,10 +18,3 @@ export function Canvas3DSetSettings(ctx: PluginContext) {
         ctx.events.canvas3d.settingsUpdated.next();
     })
 }
-
-export function InteractivitySetProps(ctx: PluginContext) {
-    PluginCommands.Interactivity.SetProps.subscribe(ctx, e => {
-        ctx.managers.interactivity.setProps(e.props);
-        ctx.events.interactivity.propsUpdated.next();
-    })
-}

+ 2 - 2
src/mol-plugin/behavior/static/state.ts

@@ -92,10 +92,10 @@ export function ToggleExpanded(ctx: PluginContext) {
 }
 
 export function ToggleVisibility(ctx: PluginContext) {
-    PluginCommands.State.ToggleVisibility.subscribe(ctx, ({ state, ref }) => setVisibility(state, ref, !state.cells.get(ref)!.state.isHidden));
+    PluginCommands.State.ToggleVisibility.subscribe(ctx, ({ state, ref }) => setSubtreeVisibility(state, ref, !state.cells.get(ref)!.state.isHidden));
 }
 
-function setVisibility(state: State, root: StateTransform.Ref, value: boolean) {
+export function setSubtreeVisibility(state: State, root: StateTransform.Ref, value: boolean) {
     StateTree.doPreOrder(state.tree, state.transforms.get(root), { state, value }, setVisibilityVisitor);
 }
 

+ 0 - 2
src/mol-plugin/commands.ts

@@ -11,7 +11,6 @@ import { Canvas3DProps } from '../mol-canvas3d/canvas3d';
 import { PluginLayoutStateProps } from './layout';
 import { StructureElement } from '../mol-model/structure';
 import { PluginState } from './state';
-import { InteractivityManager } from '../mol-plugin-state/manager/interactivity';
 import { PluginToast } from './util/toast';
 import { Vec3 } from '../mol-math/linear-algebra';
 
@@ -44,7 +43,6 @@ export const PluginCommands = {
         }
     },
     Interactivity: {
-        SetProps: PluginCommand<{ props: Partial<InteractivityManager.Props> }>(),
         Structure: {
             Highlight: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>(),
             Select: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>()

+ 0 - 3
src/mol-plugin/context.ts

@@ -76,9 +76,6 @@ export class PluginContext {
         canvas3d: {
             initialized: this.ev(),
             settingsUpdated: this.ev()
-        },
-        interactivity: {
-            propsUpdated: this.ev()
         }
     } as const
 

+ 1 - 1
src/mol-plugin/state.ts

@@ -72,7 +72,7 @@ class PluginState {
             if (snapshot.canvas3d.props) await PluginCommands.Canvas3D.SetSettings(this.plugin, { settings: snapshot.canvas3d.props });
         }
         if (snapshot.interactivity) {
-            if (snapshot.interactivity.props) await PluginCommands.Interactivity.SetProps(this.plugin, { props: snapshot.interactivity.props });
+            if (snapshot.interactivity.props) this.plugin.managers.interactivity.setProps(snapshot.interactivity.props);
         }
         if (snapshot.cameraSnapshots) this.cameraSnapshots.setStateSnapshot(snapshot.cameraSnapshots);
         if (snapshot.animation) {

+ 6 - 3
src/mol-util/param-definition.ts

@@ -92,11 +92,14 @@ export namespace ParamDefinition {
     export interface MultiSelect<E extends string, T = E[]> extends Base<T> {
         type: 'multi-select'
         /** array of (value, label) tuples */
-        options: readonly (readonly [E, string])[]
+        options: readonly (readonly [E, string])[],
+        emptyValue?: string
     }
-    export function MultiSelect<E extends string, T = E[]>(defaultValue: T, options: readonly (readonly [E, string])[], info?: Info): MultiSelect<E, T> {
+    export function MultiSelect<E extends string, T = E[]>(defaultValue: T, options: readonly (readonly [E, string])[], info?: Info & { emptyValue?: string }): MultiSelect<E, T> {
         // TODO: check if default value is a subset of options?
-        return setInfo<MultiSelect<E, T>>({ type: 'multi-select', defaultValue, options }, info)
+        const ret = setInfo<MultiSelect<E, T>>({ type: 'multi-select', defaultValue, options }, info);
+        if (info?.emptyValue) ret.emptyValue = info.emptyValue;
+        return ret;
     }
 
     export interface BooleanParam extends Base<boolean> {