From 3b7616bd372a5f31666969af042d3414d52bb511 Mon Sep 17 00:00:00 2001 From: David Sehnal <david.sehnal@gmail.com> Date: Fri, 8 Mar 2019 16:23:13 +0100 Subject: [PATCH] mol-plugin: ObjectList control --- .../skin/base/components/controls-base.scss | 4 + src/mol-plugin/skin/base/components/temp.scss | 16 ++ src/mol-plugin/state/actions/structure.ts | 8 +- src/mol-plugin/state/transforms/data.ts | 14 +- src/mol-plugin/state/transforms/misc.ts | 2 +- src/mol-plugin/state/transforms/model.ts | 12 +- src/mol-plugin/state/transforms/volume.ts | 4 +- src/mol-plugin/ui/controls/parameters.tsx | 161 ++++++++++++++++-- src/mol-util/param-definition.ts | 8 +- 9 files changed, 193 insertions(+), 36 deletions(-) diff --git a/src/mol-plugin/skin/base/components/controls-base.scss b/src/mol-plugin/skin/base/components/controls-base.scss index 6cc81d947..e6cc0adb8 100644 --- a/src/mol-plugin/skin/base/components/controls-base.scss +++ b/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; diff --git a/src/mol-plugin/skin/base/components/temp.scss b/src/mol-plugin/skin/base/components/temp.scss index cad3a2eb6..27713d16f 100644 --- a/src/mol-plugin/skin/base/components/temp.scss +++ b/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 + } } \ No newline at end of file diff --git a/src/mol-plugin/state/actions/structure.ts b/src/mol-plugin/state/actions/structure.ts index 1d817f77a..d0884d766 100644 --- a/src/mol-plugin/state/actions/structure.ts +++ b/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) diff --git a/src/mol-plugin/state/transforms/data.ts b/src/mol-plugin/state/transforms/data.ts index f3d2c0b4f..d04b01d59 100644 --- a/src/mol-plugin/state/transforms/data.ts +++ b/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 }) { diff --git a/src/mol-plugin/state/transforms/misc.ts b/src/mol-plugin/state/transforms/misc.ts index 9c8190dd1..86f3d9611 100644 --- a/src/mol-plugin/state/transforms/misc.ts +++ b/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 }) { diff --git a/src/mol-plugin/state/transforms/model.ts b/src/mol-plugin/state/transforms/model.ts index 7cdfaa2a4..052ec2ba9 100644 --- a/src/mol-plugin/state/transforms/model.ts +++ b/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 }) { diff --git a/src/mol-plugin/state/transforms/volume.ts b/src/mol-plugin/state/transforms/volume.ts index 2401cb0cf..399293a21 100644 --- a/src/mol-plugin/state/transforms/volume.ts +++ b/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' })) }; } })({ diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx index 241efa311..8267741e1 100644 --- a/src/mol-plugin/ui/controls/parameters.tsx +++ b/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> + </> } } diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts index bd3561172..e2ce230f3 100644 --- a/src/mol-util/param-definition.ts +++ b/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', -- GitLab