Pārlūkot izejas kodu

mol-plugin-ui: simplified ActionMenu, fixed residue/amk selection

David Sehnal 5 gadi atpakaļ
vecāks
revīzija
7e443d5c9b

+ 98 - 226
src/mol-plugin-ui/controls/action-menu.tsx

@@ -6,255 +6,66 @@
 
 import * as React from 'react'
 import { Icon } from './common';
-import { Subscription, BehaviorSubject, Observable } from 'rxjs';
 import { ParamDefinition } from '../../mol-util/param-definition';
 
-export class ActionMenu {
-    private _command: BehaviorSubject<ActionMenu.Command>;
+export class ActionMenu extends React.PureComponent<ActionMenu.Props> {
+    hide = () => this.props.onSelect(void 0)
 
-    get commands(): Observable<ActionMenu.Command> { return this._command; }
+    render() {
+        const cmd = this.props;
 
-    hide() {
-        this._command.next(HideCmd)
-    }
-
-    toggle(params: { items: ActionMenu.Spec, header?: string, current?: ActionMenu.Item, onSelect: (value: any) => void }) {
-        this._command.next({ type: 'toggle', ...params });
-    }
-
-    constructor(defaultCommand?: ActionMenu.Command) {
-        this._command = new BehaviorSubject<ActionMenu.Command>(defaultCommand || { type: 'hide' });
+        return <div className='msp-action-menu-options' style={{ marginTop: '1px' }}>
+            {cmd.header && <div className='msp-control-group-header' style={{ position: 'relative' }}>
+                <button className='msp-btn msp-btn-block' onClick={this.hide}>
+                    <Icon name='off' style={{ position: 'absolute', right: '2px', top: 0 }} />
+                    <b>{cmd.header}</b>
+                </button>
+            </div>}
+            <Section items={cmd.items} onSelect={cmd.onSelect} current={cmd.current} />
+        </div>
     }
 }
 
-const HideCmd: ActionMenu.Command = { type: 'hide' };
-
 export namespace ActionMenu {
-    export type Command =
-        | { type: 'toggle', items: Spec, header?: string, current?: Item, onSelect: (value: any) => void }
-        | { type: 'hide' }
-
-    function isToggleOff(a: Command, b: Command) {
-        if (a.type === 'hide' || b.type === 'hide') return false;
-        return a.onSelect === b.onSelect && a.items === b.items;
-    }
-
-
-    export type ToggleProps = {
-        style?: React.CSSProperties,
-        className?: string,
-        menu: ActionMenu,
-        disabled?: boolean,
-        items: ActionMenu.Spec,
-        header?: string,
-        label?: string,
-        current?: Item,
-        title?: string,
-        onSelect: (value: any) => void
-    }
-
-    type ToggleState = { current?: Item, isSelected: boolean }
-
-    export class Toggle extends React.PureComponent<ToggleProps, ToggleState> {
-        private sub: Subscription | undefined = void 0;
-
-        state = { isSelected: false, current: this.props.current };
-
-        componentDidMount() {
-            this.sub = this.props.menu.commands.subscribe(command => {
-                if (command.type === 'hide') {
-                    this.hide();
-                } else if (command.type === 'toggle') {
-                    const cmd = this.props;
-                    if (command.items === cmd.items && command.onSelect === cmd.onSelect) {
-                        this.setState({ isSelected: !this.state.isSelected });
-                    } else {
-                        this.hide();
-                    }
-                }
-            });
-        }
-
-        componentWillUnmount() {
-            if (!this.sub) return;
-            this.sub.unsubscribe();
-            this.sub = void 0;
-        }
-
-        hide = () => this.setState({ isSelected: false });
-
-        onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
-            e.currentTarget.blur();
-            this.props.menu.toggle(this.props);
-        }
-
-        static getDerivedStateFromProps(props: ToggleProps, state: ToggleState) {
-            if (props.current === state.current) return null;
-            return { isSelected: false, current: props.current };
-        }
-
-        render() {
-            const props = this.props;
-            const label = props.label || props.header;
-            return <button onClick={this.onClick} title={this.props.title}
-                disabled={props.disabled} style={props.style} className={props.className}>
-                {this.state.isSelected ? <b>{label}</b> : label}
-            </button>;
-        }
-    }
-
-    type  OptionsProps = { menu: ActionMenu, header?: string, items?: Spec, current?: Item | undefined }
-
-    export class Options extends React.PureComponent<OptionsProps, { command: Command, isVisible: boolean }> {
-        private sub: Subscription | undefined = void 0;
-
-        state = { isVisible: false, command: HideCmd };
-
-        componentDidMount() {
-            this.sub = this.props.menu.commands.subscribe(command => {
-                if (command.type === 'hide' || isToggleOff(command, this.state.command)) {
-                    this.setState({ isVisible: false, command: HideCmd });
-                } else {
-                    this.setState({ isVisible: true, command })
-                }
-            });
-        }
-
-        componentWillUnmount() {
-            if (!this.sub) return;
-            this.sub.unsubscribe();
-            this.sub = void 0;
-        }
-
-        onSelect: OnSelect = item => {
-            const cmd = this.state.command;
-            this.hide();
-            if (cmd.type === 'toggle') cmd.onSelect(item.value);
-        }
-
-        hide = () => {
-            this.props.menu.hide();
-        }
-
-        render() {
-            const cmd = this.state.command;
-            if (!this.state.isVisible || cmd.type !== 'toggle') return null;
-
-            if (this.props.items) {
-                if (cmd.items !== this.props.items || cmd.current !== this.props.current) return null;
-            }
+    export type Props = { items: Items, onSelect: OnSelect, header?: string, current?: Item | undefined }
 
-            return <div className='msp-action-menu-options' style={{ marginTop: '1px' }}>
-                {cmd.header && <div className='msp-control-group-header' style={{ position: 'relative' }}>
-                    <button className='msp-btn msp-btn-block' onClick={this.hide}>
-                        <Icon name='off' style={{ position: 'absolute', right: '2px', top: 0 }} />
-                        <b>{cmd.header}</b>
-                    </button>
-                </div>}
-                <Section menu={this.props.menu} items={cmd.items} onSelect={this.onSelect} current={cmd.current} />
-            </div>
-        }
-    }
-
-    type SectionProps = { menu: ActionMenu, header?: string, items: Spec, onSelect: OnSelect, current: Item | undefined }
-    type SectionState = { items: Spec, current: Item | undefined, isExpanded: boolean }
-
-    class Section extends React.PureComponent<SectionProps, SectionState> {
-        state = {
-            items: this.props.items,
-            current: this.props.current,
-            isExpanded: !!this.props.current && !!findCurrent(this.props.items, this.props.current.value)
-        }
-
-        toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
-            this.setState({ isExpanded: !this.state.isExpanded });
-            e.currentTarget.blur();
-        }
-
-        static getDerivedStateFromProps(props: SectionProps, state: SectionState) {
-            if (props.items === state.items && props.current === state.current) return null;
-            return { items: props.items, current: props.current, isExpanded: props.current && !!findCurrent(props.items, props.current.value) }
-        }
-
-        render() {
-            const { header, items, onSelect, current, menu } = this.props;
+    export type OnSelect = (item: Item | undefined) => void
 
-            if (typeof items === 'string') return null;
-            if (isItem(items)) return <Action menu={menu} item={items} onSelect={onSelect} current={current} />
+    export type Items = string | Item | [Items]
+    export type Item = { label: string, icon?: string, value: unknown }
 
-            const hasCurrent = header && current && !!findCurrent(items, current.value)
-
-            return <div>
-                {header && <div className='msp-control-group-header' style={{ marginTop: '1px' }}>
-                    <button className='msp-btn msp-btn-block' onClick={this.toggleExpanded}>
-                        <span className={`msp-icon msp-icon-${this.state.isExpanded ? 'collapse' : 'expand'}`} />
-                        {hasCurrent ? <b>{header}</b> : header}
-                    </button>
-                </div>}
-                <div className='msp-control-offset'>
-                    {(!header || this.state.isExpanded) && items.map((x, i) => {
-                        if (typeof x === 'string') return null;
-                        if (isItem(x)) return <Action menu={menu} key={i} item={x} onSelect={onSelect} current={current} />
-                        return <Section menu={menu} key={i} header={typeof x[0] === 'string' ? x[0] : void 0} items={x} onSelect={onSelect} current={current} />
-                    })}
-                </div>
-            </div>;
-        }
-    }
-
-    const Action: React.FC<{ menu: ActionMenu, item: Item, onSelect: OnSelect, current: Item | undefined }> = ({ menu, item, onSelect, current }) => {
-        const isCurrent = current === item;
-        return <div className='msp-control-row'>
-            <button onClick={isCurrent ? () => menu.hide() : () => onSelect(item)}>
-                {item.icon && <Icon name={item.icon} />}
-                {isCurrent ? <b>{item.name}</b> : item.name}
-            </button>
-        </div>;
-    }
-
-    type OnSelect = (item: Item) => void
-
-    function isItem(x: any): x is Item {
-        const v = x as Item;
-        return v && !!v.name && typeof v.value !== 'undefined';
-    }
-
-    export type OptionsParams = { items: Spec, header?: string, onSelect: (value: any) => void }
-    export type Spec = string | Item | [Spec]
-    export type Item = { name: string, icon?: string, value: unknown }
-
-    export function Item(name: string, value: unknown): Item
-    export function Item(name: string, icon: string, value: unknown): Item
-    export function Item(name: string, iconOrValue: any, value?: unknown): Item {
-        if (value) return { name, icon: iconOrValue, value };
-        return { name, value: iconOrValue };
+    export function Item(label: string, value: unknown): Item
+    export function Item(label: string, icon: string, value: unknown): Item
+    export function Item(label: string, iconOrValue: any, value?: unknown): Item {
+        if (value) return { label, icon: iconOrValue, value };
+        return { label, value: iconOrValue };
     }
 
     function createSpecFromSelectParamSimple(param: ParamDefinition.Select<any>) {
-        const spec: Item[] = [];
+        const items: Item[] = [];
         for (const [v, l] of param.options) {
-            spec.push(ActionMenu.Item(l, v));
+            items.push(ActionMenu.Item(l, v));
         }
-        return spec as Spec;
+        return items as Items;
     }
 
     function createSpecFromSelectParamCategories(param: ParamDefinition.Select<any>) {
         const cats = new Map<string, (Item | string)[]>();
-        const spec: (Item | (Item | string)[] | string)[] = [];
+        const items: (Item | (Item | string)[] | string)[] = [];
         for (const [v, l, c] of param.options) {
             if (!!c) {
                 let cat = cats.get(c);
                 if (!cat) {
                     cat = [c];
                     cats.set(c, cat);
-                    spec.push(cat);
+                    items.push(cat);
                 }
                 cat.push(ActionMenu.Item(l, v));
             } else {
-                spec.push(ActionMenu.Item(l, v));
+                items.push(ActionMenu.Item(l, v));
             }
         }
-        return spec as Spec;
+        return items as Items;
     }
 
     export function createSpecFromSelectParam(param: ParamDefinition.Select<any>) {
@@ -264,21 +75,82 @@ export namespace ActionMenu {
         return createSpecFromSelectParamSimple(param);
     }
 
-    export function findCurrent(spec: Spec, value: any): Item | undefined {
-        if (typeof spec === 'string') return;
-        if (isItem(spec)) return spec.value === value ? spec : void 0;
-        for (const s of spec) {
+    export function findCurrent(items: Items, value: any): Item | undefined {
+        if (typeof items === 'string') return;
+        if (isItem(items)) return items.value === value ? items : void 0;
+        for (const s of items) {
             const found = findCurrent(s, value);
             if (found) return found;
         }
     }
 
-    export function getFirstItem(spec: Spec): Item | undefined {
-        if (typeof spec === 'string') return;
-        if (isItem(spec)) return spec;
-        for (const s of spec) {
+    export function getFirstItem(items: Items): Item | undefined {
+        if (typeof items === 'string') return;
+        if (isItem(items)) return items;
+        for (const s of items) {
             const found = getFirstItem(s);
             if (found) return found;
         }
     }
+}
+
+type SectionProps = { header?: string, items: ActionMenu.Items, onSelect: ActionMenu.OnSelect, current: ActionMenu.Item | undefined }
+type SectionState = { items: ActionMenu.Items, current: ActionMenu.Item | undefined, isExpanded: boolean }
+
+class Section extends React.PureComponent<SectionProps, SectionState> {
+    state = {
+        items: this.props.items,
+        current: this.props.current,
+        isExpanded: !!this.props.current && !!ActionMenu.findCurrent(this.props.items, this.props.current.value)
+    }
+
+    toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
+        this.setState({ isExpanded: !this.state.isExpanded });
+        e.currentTarget.blur();
+    }
+
+    static getDerivedStateFromProps(props: SectionProps, state: SectionState) {
+        if (props.items === state.items && props.current === state.current) return null;
+        return { items: props.items, current: props.current, isExpanded: props.current && !!ActionMenu.findCurrent(props.items, props.current.value) }
+    }
+
+    render() {
+        const { header, items, onSelect, current } = this.props;
+
+        if (typeof items === 'string') return null;
+        if (isItem(items)) return <Action item={items} onSelect={onSelect} current={current} />
+
+        const hasCurrent = header && current && !!ActionMenu.findCurrent(items, current.value)
+
+        return <div>
+            {header && <div className='msp-control-group-header' style={{ marginTop: '1px' }}>
+                <button className='msp-btn msp-btn-block' onClick={this.toggleExpanded}>
+                    <span className={`msp-icon msp-icon-${this.state.isExpanded ? 'collapse' : 'expand'}`} />
+                    {hasCurrent ? <b>{header}</b> : header}
+                </button>
+            </div>}
+            <div className='msp-control-offset'>
+                {(!header || this.state.isExpanded) && items.map((x, i) => {
+                    if (typeof x === 'string') return null;
+                    if (isItem(x)) return <Action key={i} item={x} onSelect={onSelect} current={current} />
+                    return <Section key={i} header={typeof x[0] === 'string' ? x[0] : void 0} items={x} onSelect={onSelect} current={current} />
+                })}
+            </div>
+        </div>;
+    }
+}
+
+const Action: React.FC<{ item: ActionMenu.Item, onSelect: ActionMenu.OnSelect, current: ActionMenu.Item | undefined }> = ({ item, onSelect, current }) => {
+    const isCurrent = current === item;
+    return <div className='msp-control-row'>
+        <button onClick={() => onSelect(item)}>
+            {item.icon && <Icon name={item.icon} />}
+            {isCurrent ? <b>{item.label}</b> : item.label}
+        </button>
+    </div>;
+}
+
+function isItem(x: any): x is ActionMenu.Item {
+    const v = x as ActionMenu.Item;
+    return v && !!v.label && typeof v.value !== 'undefined';
 }

+ 25 - 13
src/mol-plugin-ui/controls/common.tsx

@@ -308,16 +308,28 @@ export function SectionHeader(props: { icon?: string, title: string | JSX.Elemen
     </div>
 }
 
-// export const ToggleButton = (props: {
-//     onChange: (v: boolean) => void,
-//     value: boolean,
-//     label: string,
-//     title?: string
-// }) => <div className='lm-control-row lm-toggle-button' title={props.title}>
-//         <span>{props.label}</span>
-//         <div>
-//             <button onClick={e => { props.onChange.call(null, !props.value); (e.target as HTMLElement).blur(); }}>
-//                     <span className={ `lm-icon lm-icon-${props.value ? 'ok' : 'off'}` }></span> {props.value ? 'On' : 'Off'}
-//             </button>
-//         </div>
-//     </div>
+export type ToggleButtonProps = {
+    style?: React.CSSProperties,
+    className?: string,
+    disabled?: boolean,
+    label: string | JSX.Element,
+    title?: string,
+    isSelected?: boolean,
+    toggle: () => void
+}
+
+export class ToggleButton extends React.PureComponent<ToggleButtonProps> {
+    onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
+        e.currentTarget.blur();
+        this.props.toggle();
+    }
+
+    render() {
+        const props = this.props;
+        const label = props.label;
+        return <button onClick={this.onClick} title={this.props.title}
+            disabled={props.disabled} style={props.style} className={props.className}>
+            {this.props.isSelected ? <b>{label}</b> : label}
+        </button>;
+    }
+}

+ 40 - 18
src/mol-plugin-ui/controls/parameters.tsx

@@ -14,7 +14,7 @@ import { camelCaseToWords } from '../../mol-util/string';
 import * as React from 'react';
 import LineGraphComponent from './line-graph/line-graph-component';
 import { Slider, Slider2 } from './slider';
-import { NumericInput, IconButton, ControlGroup } from './common';
+import { NumericInput, IconButton, ControlGroup, ToggleButton } from './common';
 import { _Props, _State, PluginUIComponent } from '../base';
 import { legendFor } from './legend';
 import { Legend as LegendData } from '../../mol-util/legend';
@@ -127,7 +127,7 @@ export interface ParamProps<P extends PD.Base<any> = PD.Base<any>> {
 }
 export type ParamControl = React.ComponentClass<ParamProps<any>>
 
-function renderSimple(options: { props: ParamProps<any>, state: { isExpanded: boolean }, control: JSX.Element, addOn: JSX.Element | null, toggleHelp: () => void }) {
+function renderSimple(options: { props: ParamProps<any>, state: { showHelp: boolean }, control: JSX.Element, addOn: JSX.Element | null, toggleHelp: () => void }) {
     const { props, state, control, toggleHelp, addOn } = options;
 
     const _className = ['msp-control-row'];
@@ -147,9 +147,9 @@ function renderSimple(options: { props: ParamProps<any>, state: { isExpanded: bo
                 {label}
                 {hasHelp &&
                     <button className='msp-help msp-btn-link msp-btn-icon msp-control-group-expander' onClick={toggleHelp}
-                        title={desc || `${state.isExpanded ? 'Hide' : 'Show'} help`}
+                        title={desc || `${state.showHelp ? 'Hide' : 'Show'} help`}
                         style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
-                        <span className={`msp-icon msp-icon-help-circle-${state.isExpanded ? 'collapse' : 'expand'}`} />
+                        <span className={`msp-icon msp-icon-help-circle-${state.showHelp ? 'collapse' : 'expand'}`} />
                     </button>
                 }
             </span>
@@ -157,15 +157,15 @@ function renderSimple(options: { props: ParamProps<any>, state: { isExpanded: bo
                 {control}
             </div>
         </div>
-        {hasHelp && state.isExpanded && <div className='msp-control-offset'>
+        {hasHelp && state.showHelp && <div className='msp-control-offset'>
             <ParamHelp legend={help.legend} description={help.description} />
         </div>}
         {addOn}
     </>;
 }
 
-export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<ParamProps<P>, { isExpanded: boolean }> {
-    state = { isExpanded: false };
+export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<ParamProps<P>, { showHelp: boolean }> {
+    state = { showHelp: false };
 
     protected update(value: P['defaultValue']) {
         this.props.onChange({ param: this.props.param, name: this.props.name, value });
@@ -174,7 +174,7 @@ export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<
     abstract renderControl(): JSX.Element;
     renderAddOn(): JSX.Element | null { return null; }
 
-    toggleHelp = () => this.setState({ isExpanded: !this.state.isExpanded });
+    toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
 
     render() {
         return renderSimple({
@@ -330,32 +330,54 @@ export class PureSelectControl extends  React.PureComponent<ParamProps<PD.Select
     }
 }
 
-export class SelectControl extends SimpleParam<PD.Select<string | number>> {
-    menu = new ActionMenu();
-    onSelect = (value: string) => {
-        this.update(value);
+export class SelectControl extends React.PureComponent<ParamProps<PD.Select<string | number>>, { showHelp: boolean, showOptions: boolean }> {
+    state = { showHelp: false, showOptions: false };
+
+    onSelect: ActionMenu.OnSelect = item => {
+        if (!item || item.value === this.props.value) {
+            this.setState({ showOptions: false });
+        } else {
+            this.setState({ showOptions: false }, () => {
+                this.props.onChange({ param: this.props.param, name: this.props.name, value: item.value });
+            });
+        }
     }
 
+    toggle = () => this.setState({ showOptions: !this.state.showOptions });
+
     items = memoizeLatest((param: PD.Select<any>) => ActionMenu.createSpecFromSelectParam(param));
 
     renderControl() {
         const items = this.items(this.props.param);
         const current = this.props.value ? ActionMenu.findCurrent(items, this.props.value) : void 0;
         const label = current
-            ? current.name
+            ? current.label
             : typeof this.props.value === 'undefined'
-            ? `${ActionMenu.getFirstItem(items)?.name || ''} [Default]`
+            ? `${ActionMenu.getFirstItem(items)?.label || ''} [Default]`
             : `[Invalid] ${this.props.value}`
-        return <ActionMenu.Toggle menu={this.menu} disabled={this.props.isDisabled} style={{ textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}
-            onSelect={this.onSelect} items={items as ActionMenu.Spec} label={label} title={label}
-            current={current} />;
+        return <ToggleButton disabled={this.props.isDisabled} style={{ textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}
+            label={label} title={label as string} toggle={this.toggle} isSelected={this.state.showOptions} />;
     }
 
     renderAddOn() {
+        if (!this.state.showOptions) return null;
+
         const items = this.items(this.props.param);
         const current = ActionMenu.findCurrent(items, this.props.value);
 
-        return <ActionMenu.Options menu={this.menu} items={items} current={current} />;
+        return <ActionMenu items={items} current={current} onSelect={this.onSelect} />;
+    }
+
+    toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
+
+    render() {
+        return renderSimple({
+            props: this.props,
+            state: this.state,
+            control: this.renderControl(),
+            toggleHelp: this.toggleHelp,
+            addOn: this.renderAddOn()
+        });
     }
 }
 

+ 39 - 21
src/mol-plugin-ui/structure/selection.tsx

@@ -13,10 +13,10 @@ import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { Interactivity } from '../../mol-plugin/util/interactivity';
 import { ParameterControls } from '../controls/parameters';
 import { stripTags, stringToWords } from '../../mol-util/string';
-import { StructureElement, StructureSelection } from '../../mol-model/structure';
+import { StructureElement } from '../../mol-model/structure';
 import { ActionMenu } from '../controls/action-menu';
-import { compile } from '../../mol-script/runtime/query/compiler';
 import { MolScriptBuilder as MS } from '../../mol-script/language/builder';
+import { ToggleButton } from '../controls/common';
 
 const SSQ = StructureSelectionQueries
 
@@ -46,7 +46,7 @@ const StandardAminoAcids = [
     [['THR'], 'THREONINE'],
     [['SEC'], 'SELENOCYSTEINE'],
     [['PYL'], 'PYRROLYSINE'],
-] as [string[], string][]
+].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) as [string[], string][]
 
 const StandardNucleicBases = [
     [['A', 'DA'], 'ADENOSINE'],
@@ -55,15 +55,15 @@ const StandardNucleicBases = [
     [['G', 'DG'], 'GUANOSINE'],
     [['I', 'DI'], 'INOSINE'],
     [['U', 'DU'], 'URIDINE'],
-] as [string[], string][]
+].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) as [string[], string][]
 
 function ResidueItem([names, label]: [string[], string]) {
-    const query = compile<StructureSelection>(MS.struct.modifier.union([
+    const query = StructureSelectionQuery(names.join(', '), MS.struct.modifier.union([
         MS.struct.generator.atomGroups({
             'residue-test': MS.core.set.has([MS.set(...names), MS.ammp('auth_comp_id')])
         })
     ]))
-    return ActionMenu.Item(stringToWords(label), query)
+    return ActionMenu.Item(`${names.join(', ')} (${stringToWords(label)})`, query)
 }
 
 const DefaultQueries = [
@@ -107,7 +107,7 @@ const DefaultQueries = [
         'Validation',
         SSQItem('hasClash'),
     ]
-] as unknown as ActionMenu.Spec
+] as unknown as ActionMenu.Items
 
 const StructureSelectionParams = {
     granularity: Interactivity.Params.granularity,
@@ -118,7 +118,9 @@ interface StructureSelectionControlsState extends CollapsableState {
     extraRadius: number,
     durationMs: number,
 
-    isDisabled: boolean
+    isDisabled: boolean,
+
+    queryAction?: SelectionModifier
 }
 
 export class StructureSelectionControls<P, S extends StructureSelectionControlsState> extends CollapsableControls<P, S> {
@@ -132,8 +134,7 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
         });
 
         this.subscribe(this.plugin.state.dataState.events.isUpdating, v => {
-            this.actionMenu.hide();
-            this.setState({ isDisabled: v })
+            this.setState({ isDisabled: v, queryAction: void 0 })
         })
     }
 
@@ -207,22 +208,37 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
         this.plugin.helpers.structureSelection.set(modifier, selectionQuery, false)
     }
 
-    add = (value: StructureSelectionQuery) => this.set('add', value)
-    remove = (value: StructureSelectionQuery) => this.set('remove', value)
-    only = (value: StructureSelectionQuery) => this.set('only', value)
+    selectQuery: ActionMenu.OnSelect = item => {
+        if (!item || !this.state.queryAction) {
+            this.setState({ queryAction: void 0 });
+            return;
+        }
+        const q = this.state.queryAction!;
+        this.setState({ queryAction: void 0 }, () => {
+            this.set(q, item.value as StructureSelectionQuery);
+        })
+    }
 
     queries = DefaultQueries
 
-    actionMenu = new ActionMenu();
+    private showQueries(q: SelectionModifier) {
+        return () => this.setState({ queryAction: this.state.queryAction === q ? void 0 : q });
+    }
+
+    toggleAdd = this.showQueries('add')
+    toggleRemove = this.showQueries('remove')
+    toggleOnly = this.showQueries('only')
 
-    controls = <div>
-        <div className='msp-control-row msp-button-row'>
-            <ActionMenu.Toggle menu={this.actionMenu} items={this.queries} header='Select' onSelect={this.add} disabled={this.state.isDisabled} />
-            <ActionMenu.Toggle menu={this.actionMenu} items={this.queries} header='Deselect' onSelect={this.remove} disabled={this.state.isDisabled} />
-            <ActionMenu.Toggle menu={this.actionMenu} items={this.queries} header='Only' onSelect={this.only} disabled={this.state.isDisabled} />
+    get controls() {
+        return <div>
+            <div className='msp-control-row msp-button-row'>
+                <ToggleButton label='Select' toggle={this.toggleAdd} isSelected={this.state.queryAction === 'add'} disabled={this.state.isDisabled} />
+                <ToggleButton label='Deselect' toggle={this.toggleRemove} isSelected={this.state.queryAction === 'remove'} disabled={this.state.isDisabled} />
+                <ToggleButton label='Only' toggle={this.toggleOnly} isSelected={this.state.queryAction === 'only'} disabled={this.state.isDisabled} />
+            </div>
+            {this.state.queryAction && <ActionMenu items={this.queries} onSelect={this.selectQuery} />}
         </div>
-        <ActionMenu.Options menu={this.actionMenu} />
-    </div>
+    }
 
     defaultState() {
         return {
@@ -233,6 +249,8 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
             extraRadius: 4,
             durationMs: 250,
 
+            queryAction: void 0,
+
             isDisabled: false
         } as S
     }