Browse Source

mol-plugin: ObjectList control

David Sehnal 6 years ago
parent
commit
3b7616bd37

+ 4 - 0
src/mol-plugin/skin/base/components/controls-base.scss

@@ -118,6 +118,10 @@
     }
 }
 
+.msp-control-top-offset {
+    margin-top: 1px;
+}
+
 .msp-btn-commit {        
     text-align: right;
     padding-top: 0;

+ 16 - 0
src/mol-plugin/skin/base/components/temp.scss

@@ -178,4 +178,20 @@
             width: 200px;
         }
     }
+}
+
+.msp-param-object-list-item {
+    margin-top: 1px;
+    position: relative;
+    > button {
+        text-align: left;
+        > span {
+            font-weight: bold;
+        }
+    }
+    > div {
+        position: absolute;
+        right: 0;
+        top: 0
+    }
 }

+ 4 - 4
src/mol-plugin/state/actions/structure.ts

@@ -69,8 +69,8 @@ export const GroProvider: DataFormatProvider<any> = {
 //
 
 const DownloadStructurePdbIdSourceOptions = PD.Group({
-    supportProps: PD.makeOptional(PD.Boolean(false)),
-    asTrajectory: PD.makeOptional(PD.Boolean(false, { description: 'Load all entries into a single trajectory.' }))
+    supportProps: PD.asOptional(PD.Boolean(false)),
+    asTrajectory: PD.asOptional(PD.Boolean(false, { description: 'Load all entries into a single trajectory.' }))
 });
 
 export { DownloadStructure };
@@ -97,7 +97,7 @@ const DownloadStructure = StateAction.build({
                 format: PD.Select('cif', [['cif', 'CIF'], ['pdb', 'PDB']]),
                 isBinary: PD.Boolean(false),
                 options: PD.Group({
-                    supportProps: PD.makeOptional(PD.Boolean(false))
+                    supportProps: PD.asOptional(PD.Boolean(false))
                 })
             }, { isFlat: true })
         }, {
@@ -231,7 +231,7 @@ export const UpdateTrajectory = StateAction.build({
     display: { name: 'Update Trajectory' },
     params: {
         action: PD.Select<'advance' | 'reset'>('advance', [['advance', 'Advance'], ['reset', 'Reset']]),
-        by: PD.makeOptional(PD.Numeric(1, { min: -1, max: 1, step: 1 }))
+        by: PD.asOptional(PD.Numeric(1, { min: -1, max: 1, step: 1 }))
     }
 })(({ params, state }) => {
     const models = state.selectQ(q => q.rootsOfType(PluginStateObject.Molecule.Model)

+ 7 - 7
src/mol-plugin/state/transforms/data.ts

@@ -25,8 +25,8 @@ const Download = PluginStateTransform.BuiltIn({
     to: [SO.Data.String, SO.Data.Binary],
     params: {
         url: PD.Text('https://www.ebi.ac.uk/pdbe/static/entry/1cbs_updated.cif', { description: 'Resource URL. Must be the same domain or support CORS.' }),
-        label: PD.makeOptional(PD.Text('')),
-        isBinary: PD.makeOptional(PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)' }))
+        label: PD.asOptional(PD.Text('')),
+        isBinary: PD.asOptional(PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)' }))
     }
 })({
     apply({ params: p }, globalCtx: PluginContext) {
@@ -58,10 +58,10 @@ const DownloadBlob = PluginStateTransform.BuiltIn({
         sources: PD.ObjectList({
             id: PD.Text('', { label: 'Unique ID' }),
             url: PD.Text('https://www.ebi.ac.uk/pdbe/static/entry/1cbs_updated.cif', { description: 'Resource URL. Must be the same domain or support CORS.' }),
-            isBinary: PD.makeOptional(PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)' })),
-            canFail: PD.makeOptional(PD.Boolean(false, { description: 'Indicate whether the download can fail and not be included in the blob as a result.' }))
+            isBinary: PD.asOptional(PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)' })),
+            canFail: PD.asOptional(PD.Boolean(false, { description: 'Indicate whether the download can fail and not be included in the blob as a result.' }))
         }, e => `${e.id}: ${e.url}`),
-        maxConcurrency: PD.makeOptional(PD.Numeric(4, { min: 1, max: 12, step: 1 }, { description: 'The maximum number of concurrent downloads.' }))
+        maxConcurrency: PD.asOptional(PD.Numeric(4, { min: 1, max: 12, step: 1 }, { description: 'The maximum number of concurrent downloads.' }))
     }
 })({
     apply({ params }, plugin: PluginContext) {
@@ -102,8 +102,8 @@ const ReadFile = PluginStateTransform.BuiltIn({
     to: [SO.Data.String, SO.Data.Binary],
     params: {
         file: PD.File(),
-        label: PD.makeOptional(PD.Text('')),
-        isBinary: PD.makeOptional(PD.Boolean(false, { description: 'If true, open file as as binary (string otherwise)' }))
+        label: PD.asOptional(PD.Text('')),
+        isBinary: PD.asOptional(PD.Boolean(false, { description: 'If true, open file as as binary (string otherwise)' }))
     }
 })({
     apply({ params: p }) {

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

@@ -18,7 +18,7 @@ const CreateGroup = PluginStateTransform.BuiltIn({
     to: SO.Group,
     params: {
         label: PD.Text('Group'),
-        description: PD.makeOptional(PD.Text(''))
+        description: PD.asOptional(PD.Text(''))
     }
 })({
     apply({ params }) {

+ 6 - 6
src/mol-plugin/state/transforms/model.ts

@@ -71,12 +71,12 @@ const TrajectoryFromMmCif = PluginStateTransform.BuiltIn({
     params(a) {
         if (!a) {
             return {
-                blockHeader: PD.makeOptional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }))
+                blockHeader: PD.asOptional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }))
             };
         }
         const { blocks } = a.data;
         return {
-            blockHeader: PD.makeOptional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
+            blockHeader: PD.asOptional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
         };
     }
 })({
@@ -181,12 +181,12 @@ const StructureAssemblyFromModel = PluginStateTransform.BuiltIn({
     to: SO.Molecule.Structure,
     params(a) {
         if (!a) {
-            return { id: PD.makeOptional(PD.Text('', { label: 'Assembly Id', description: 'Assembly Id. Value \'deposited\' can be used to specify deposited asymmetric unit.' })) };
+            return { id: PD.asOptional(PD.Text('', { label: 'Assembly Id', description: 'Assembly Id. Value \'deposited\' can be used to specify deposited asymmetric unit.' })) };
         }
         const model = a.data;
         const ids = model.symmetry.assemblies.map(a => [a.id, `${a.id}: ${stringToWords(a.details)}`] as [string, string]);
         ids.push(['deposited', 'Deposited']);
-        return { id: PD.makeOptional(PD.Select(ids[0][0], ids, { label: 'Asm Id', description: 'Assembly Id' })) };
+        return { id: PD.asOptional(PD.Select(ids[0][0], ids, { label: 'Asm Id', description: 'Assembly Id' })) };
     }
 })({
     apply({ a, params }, plugin: PluginContext) {
@@ -258,7 +258,7 @@ const StructureSelection = PluginStateTransform.BuiltIn({
     to: SO.Molecule.Structure,
     params: {
         query: PD.Value<Expression>(MolScriptBuilder.struct.generator.all, { isHidden: true }),
-        label: PD.makeOptional(PD.Text('', { isHidden: true }))
+        label: PD.asOptional(PD.Text('', { isHidden: true }))
     }
 })({
     apply({ a, params, cache }) {
@@ -289,7 +289,7 @@ const UserStructureSelection = PluginStateTransform.BuiltIn({
     to: SO.Molecule.Structure,
     params: {
         query: PD.ScriptExpression({ language: 'mol-script', expression: '(sel.atom.atom-groups :residue-test (= atom.resname ALA))' }),
-        label: PD.makeOptional(PD.Text(''))
+        label: PD.asOptional(PD.Text(''))
     }
 })({
     apply({ a, params, cache }) {

+ 2 - 2
src/mol-plugin/state/transforms/volume.ts

@@ -68,12 +68,12 @@ const VolumeFromDensityServerCif = PluginStateTransform.BuiltIn({
     params(a) {
         if (!a) {
             return {
-                blockHeader: PD.makeOptional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }))
+                blockHeader: PD.asOptional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }))
             };
         }
         const blocks = a.data.blocks.slice(1); // zero block contains query meta-data
         return {
-            blockHeader: PD.makeOptional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
+            blockHeader: PD.asOptional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
         };
     }
 })({

+ 148 - 13
src/mol-plugin/ui/controls/parameters.tsx

@@ -15,7 +15,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 } from './common';
+import { NumericInput, IconButton } from './common';
 
 export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
     params: P,
@@ -505,27 +505,162 @@ export class MappedControl extends React.PureComponent<ParamProps<PD.Mapped<any>
             return select;
         }
 
-        return <div>
+        return <>
             {select}
             <Mapped param={param} value={value.params} name={`${label} Properties`} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />
-        </div>
+        </>
     }
 }
 
+type _Props<C extends React.Component> = C extends React.Component<infer P> ? P : never
+type _State<C extends React.Component> = C extends React.Component<any, infer S> ? S : never
+
+class ObjectListEditor extends React.PureComponent<{ params: PD.Params, value: object, isUpdate?: boolean, apply: (value: any) => void, isDisabled?: boolean }, { params: PD.Params, value: object, current: object }> {
+    state = { params: {}, value: void 0 as any, current: void 0 as any };
+
+    onChangeParam: ParamOnChange = e => {
+        this.setState({ current: { ...this.state.current, [e.name]: e.value } });
+    }
+
+    apply = () => {
+        this.props.apply(this.state.current);
+    }
+
+    static getDerivedStateFromProps(props: _Props<ObjectListEditor>, state: _State<ObjectListEditor>): _State<ObjectListEditor> | null {
+        if (props.params === state.params && props.value === state.value) return null;
+        return {
+            params: props.params,
+            value: props.value,
+            current: props.value
+        };
+    }
+
+    render() {
+        return <>
+            <ParameterControls params={this.props.params} onChange={this.onChangeParam} values={this.state.current} onEnter={this.apply} isDisabled={this.props.isDisabled} />
+            <button className={`msp-btn msp-btn-block msp-form-control msp-control-top-offset`} onClick={this.apply} disabled={this.props.isDisabled}>
+                {this.props.isUpdate ? 'Update' : 'Add'}
+            </button>
+        </>;
+    }
+}
+
+class ObjectListItem extends React.PureComponent<{ param: PD.ObjectList, value: object, index: number, actions: ObjectListControl['actions'], isDisabled?: boolean }, { isExpanded: boolean }> {
+    state = { isExpanded: false };
+
+    update = (v: object) => {
+        this.setState({ isExpanded: false });
+        this.props.actions.update(v, this.props.index);
+    }
+
+    moveUp = () => {
+        this.props.actions.move(this.props.index, -1);
+    };
+
+    moveDown = () => {
+        this.props.actions.move(this.props.index, 1);
+    };
+
+    remove = () => {
+        this.setState({ isExpanded: false });
+        this.props.actions.remove(this.props.index);
+    };
+
+    toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
+        this.setState({ isExpanded: !this.state.isExpanded });
+        e.currentTarget.blur();
+    };
+
+    static getDerivedStateFromProps(props: _Props<ObjectListEditor>, state: _State<ObjectListEditor>): _State<ObjectListEditor> | null {
+        if (props.params === state.params && props.value === state.value) return null;
+        return {
+            params: props.params,
+            value: props.value,
+            current: props.value
+        };
+    }
+
+    render() {
+        return <>
+            <div className='msp-param-object-list-item'>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.toggleExpanded}>
+                    <span>{`${this.props.index + 1}: `}</span>
+                    {this.props.param.getLabel(this.props.value)}
+                </button>
+                <div>
+                    <IconButton icon='up-thin' title='Move Up' onClick={this.moveUp} isSmall={true} />
+                    <IconButton icon='down-thin' title='Move Down' onClick={this.moveDown} isSmall={true} />
+                    <IconButton icon='remove' title='Remove' onClick={this.remove} isSmall={true} />
+                </div>
+            </div>
+            {this.state.isExpanded && <div className='msp-control-offset'>
+                <ObjectListEditor params={this.props.param.element} apply={this.update} value={this.props.value} isUpdate isDisabled={this.props.isDisabled} />
+            </div>}
+        </>;
+    }
+}
 
 export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean }> {
-    // state = { isExpanded: !!this.props.param.isExpanded }
+    state = { isExpanded: false }
+
+    change(value: any) {
+        this.props.onChange({ name: this.props.name, param: this.props.param, value });
+    }
 
-    // change(value: any) {
-    //     this.props.onChange({ name: this.props.name, param: this.props.param, value });
-    // }
+    add = (v: object) => {
+        this.change([...this.props.value, v]);
+    };
+
+    actions = {
+        update: (v: object, i: number) => {
+            const value = this.props.value.slice(0);
+            value[i] = v;
+            this.change(value);
+        },
+        move: (i: number, dir: -1 | 1) => {
+            let xs = this.props.value;
+            if (xs.length === 1) return;
+
+            let j = (i + dir) % xs.length;
+            if (j < 0) j += xs.length;
+
+            xs = xs.slice(0);
+            const t = xs[i];
+            xs[i] = xs[j];
+            xs[j] = t;
+            this.change(xs);
+        },
+        remove: (i: number) => {
+            const xs = this.props.value;
+            const update: object[] = [];
+            for (let j = 0; j < xs.length; j++) {
+                if (i !== j) update.push(xs[j]);
+            }
+            this.change(update);
+        }
+    }
 
-    // onChangeParam: ParamOnChange = e => {
-    //     this.change({ ...this.props.value, [e.name]: e.value });
-    // }
+    toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
+        this.setState({ isExpanded: !this.state.isExpanded });
+        e.currentTarget.blur();
+    };
 
     render() {
-        return <span>TODO</span>;
+        const v = this.props.value;
+        const label = this.props.param.label || camelCaseToWords(this.props.name);
+        const value = `${v.length} item${v.length !== 1 ? 's' : ''}`;
+        return <>
+            <div className='msp-control-row'>
+                <span>{label}</span>
+                <div>
+                    <button onClick={this.toggleExpanded}>{value}</button>
+                </div>
+            </div>
+            {this.state.isExpanded && <div className='msp-control-offset'>
+                {this.props.value.map((v, i) => <ObjectListItem key={i} param={this.props.param} value={v} index={i} actions={this.actions} />)}
+                <ObjectListEditor params={this.props.param.element} apply={this.add} value={this.props.param.ctor()} isDisabled={this.props.isDisabled} />
+            </div>}
+        </>;
     }
 }
 
@@ -557,10 +692,10 @@ export class ConditionedControl extends React.PureComponent<ParamProps<PD.Condit
             return select;
         }
 
-        return <div>
+        return <>
             {select}
             <Conditioned param={param} value={value} name={label} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />
-        </div>
+        </>
     }
 }
 

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

@@ -36,7 +36,7 @@ export namespace ParamDefinition {
         type: T['type']
     }
 
-    export function makeOptional<T>(p: Base<T>): Base<T | undefined> {
+    export function asOptional<T>(p: Base<T>): Base<T | undefined> {
         p.isOptional = true;
         return p;
     }
@@ -196,11 +196,13 @@ export namespace ParamDefinition {
     export interface ObjectList<T = any> extends Base<T[]> {
         type: 'object-list',
         element: Params,
+        ctor(): T,
         getLabel(t: T): string
     }
-    export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[] }): ObjectList<Normalize<T>> {
-        return setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, defaultValue: (info && info.defaultValue) || []  });
+    export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T }): ObjectList<Normalize<T>> {
+        return setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info && info.defaultValue) || []  });
     }
+    function _defaultObjectListCtor(this: ObjectList) { return getDefaultValues(this.element) as any; }
 
     export interface Converted<T, C> extends Base<T> {
         type: 'converted',