diff --git a/CHANGELOG.md b/CHANGELOG.md index 107e125d13f93b5eb549f7db10ec782ee31cbaa6..e9ff693dc26143f04565512660bfb48933afa824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file, following t Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here. +## [Unreleased] + +- Ability to pass ``Canvas3DContext`` to ``PluginContext.fromCanvas``. +- Relative frame support for ``Canvas3D`` viewport. +- Fix bug in screenshot copy UI. +- Add ability to select residues from a list of identifiers to the Selection UI. + ## [v2.0.4] - 2021-04-20 - [WIP] Mesh export extension diff --git a/src/mol-plugin-state/manager/structure/selection.ts b/src/mol-plugin-state/manager/structure/selection.ts index fdb18167586abd1236fbe392e566576597ae8dc1..cc291447b69fa99b6a85d6487db58251321bc7ee 100644 --- a/src/mol-plugin-state/manager/structure/selection.ts +++ b/src/mol-plugin-state/manager/structure/selection.ts @@ -10,7 +10,7 @@ import { BoundaryHelper } from '../../../mol-math/geometry/boundary-helper'; import { Vec3 } from '../../../mol-math/linear-algebra'; import { PrincipalAxes } from '../../../mol-math/linear-algebra/matrix/principal-axes'; import { EmptyLoci, Loci } from '../../../mol-model/loci'; -import { Structure, StructureElement, StructureSelection } from '../../../mol-model/structure'; +import { QueryContext, Structure, StructureElement, StructureQuery, StructureSelection } from '../../../mol-model/structure'; import { PluginContext } from '../../../mol-plugin/context'; import { StateObjectRef } from '../../../mol-state'; import { Task } from '../../../mol-task'; @@ -457,6 +457,13 @@ export class StructureSelectionManager extends StatefulPluginComponent<Structure this.triggerInteraction(modifier, loci, applyGranularity); } + fromCompiledQuery(modifier: StructureSelectionModifier, query: StructureQuery, applyGranularity = true) { + for (const s of this.applicableStructures) { + const loci = query(new QueryContext(s)); + this.triggerInteraction(modifier, StructureSelection.toLociWithSourceUnits(loci), applyGranularity); + } + } + fromSelectionQuery(modifier: StructureSelectionModifier, query: StructureSelectionQuery, applyGranularity = true) { this.plugin.runTask(Task.create('Structure Selection', async runtime => { for (const s of this.applicableStructures) { diff --git a/src/mol-plugin-ui/structure/selection.tsx b/src/mol-plugin-ui/structure/selection.tsx index 9ef8ee808b719567554a20556700f53aea5017f8..151a8240e931b49b519bed6680f3e1bb6a034a11 100644 --- a/src/mol-plugin-ui/structure/selection.tsx +++ b/src/mol-plugin-ui/structure/selection.tsx @@ -6,22 +6,24 @@ */ import * as React from 'react'; -import { StructureSelectionQueries, StructureSelectionQuery, getNonStandardResidueQueries, getElementQueries, getPolymerAndBranchedEntityQueries } from '../../mol-plugin-state/helpers/structure-selection-query'; +import { Structure } from '../../mol-model/structure/structure/structure'; +import { getElementQueries, getNonStandardResidueQueries, getPolymerAndBranchedEntityQueries, StructureSelectionQueries, StructureSelectionQuery } from '../../mol-plugin-state/helpers/structure-selection-query'; import { InteractivityManager } from '../../mol-plugin-state/manager/interactivity'; import { StructureComponentManager } from '../../mol-plugin-state/manager/structure/component'; -import { StructureRef, StructureComponentRef } from '../../mol-plugin-state/manager/structure/hierarchy-state'; +import { StructureComponentRef, StructureRef } from '../../mol-plugin-state/manager/structure/hierarchy-state'; import { StructureSelectionModifier } from '../../mol-plugin-state/manager/structure/selection'; +import { PluginContext } from '../../mol-plugin/context'; +import { compileResidueListSelection } from '../../mol-script/util/residue-list'; import { memoizeLatest } from '../../mol-util/memoize'; import { ParamDefinition } from '../../mol-util/param-definition'; -import { stripTags } from '../../mol-util/string'; +import { capitalize, stripTags } from '../../mol-util/string'; import { PluginUIComponent, PurePluginUIComponent } from '../base'; import { ActionMenu } from '../controls/action-menu'; import { Button, ControlGroup, IconButton, ToggleButton } from '../controls/common'; +import { BrushSvg, CancelOutlinedSvg, CloseSvg, CubeOutlineSvg, HelpOutlineSvg, Icon, IntersectSvg, RemoveSvg, RestoreSvg, SelectionModeSvg, SetSvg, SubtractSvg, UnionSvg } from '../controls/icons'; import { ParameterControls, ParamOnChange, PureSelectControl } from '../controls/parameters'; -import { UnionSvg, SubtractSvg, IntersectSvg, SetSvg, CubeOutlineSvg, Icon, SelectionModeSvg, RemoveSvg, RestoreSvg, HelpOutlineSvg, CancelOutlinedSvg, BrushSvg, CloseSvg } from '../controls/icons'; +import { HelpGroup, HelpText, ViewportHelpContent } from '../viewport/help'; import { AddComponentControls } from './components'; -import { Structure } from '../../mol-model/structure/structure/structure'; -import { ViewportHelpContent, HelpGroup, HelpText } from '../viewport/help'; export class ToggleSelectionModeButton extends PurePluginUIComponent<{ inline?: boolean }> { @@ -47,12 +49,15 @@ const StructureSelectionParams = { granularity: InteractivityManager.Params.granularity, }; +type SelectionHelperType = 'residue-list' + interface StructureSelectionActionsControlsState { isEmpty: boolean, isBusy: boolean, canUndo: boolean, - action?: StructureSelectionModifier | 'theme' | 'add-component' | 'help' + action?: StructureSelectionModifier | 'theme' | 'add-component' | 'help', + helper?: SelectionHelperType, } const ActionHeader = new Map<StructureSelectionModifier, string>([ @@ -65,6 +70,7 @@ const ActionHeader = new Map<StructureSelectionModifier, string>([ export class StructureSelectionActionsControls extends PluginUIComponent<{}, StructureSelectionActionsControlsState> { state = { action: void 0 as StructureSelectionActionsControlsState['action'], + helper: void 0 as StructureSelectionActionsControlsState['helper'], isEmpty: true, isBusy: false, @@ -118,7 +124,16 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str } } - get structures () { + selectHelper: ActionMenu.OnSelect = (item, e) => { + console.log(item); + if (!item || !this.state.action) { + this.setState({ action: void 0, helper: void 0 }); + return; + } + this.setState({ helper: (item.value as { kind: SelectionHelperType }).kind }); + } + + get structures() { const structures: Structure[] = []; for (const s of this.plugin.managers.structure.hierarchy.selection.structures) { const structure = s.cell.obj?.data; @@ -129,7 +144,7 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str private queriesItems: ActionMenu.Items[] = [] private queriesVersion = -1 - get queries () { + get queries() { const { registry } = this.plugin.query.structure; if (registry.version !== this.queriesVersion) { const structures = this.structures; @@ -150,8 +165,25 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str return this.queriesItems; } + private helpersItems?: ActionMenu.Items[] = void 0; + get helpers() { + if (this.helpersItems) return this.helpersItems; + // TODO: this is an initial implementation of the helper UI + // the plan is to add support to input queries in different languages + // after this has been implemented in mol-script + const helpers = [ + { kind: 'residue-list' as SelectionHelperType, category: 'Helpers', label: 'Residue List', description: 'Create a selection from a list of residue ranges.' } + ]; + this.helpersItems = ActionMenu.createItems(helpers, { + label: q => q.label, + category: q => q.category, + description: q => q.description + }); + return this.helpersItems; + } + private showAction(q: StructureSelectionActionsControlsState['action']) { - return () => this.setState({ action: this.state.action === q ? void 0 : q }); + return () => this.setState({ action: this.state.action === q ? void 0 : q, helper: void 0 }); } toggleAdd = this.showAction('add') @@ -187,6 +219,45 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str ? `Undo ${this.plugin.state.data.latestUndoLabel}` : 'Some mistakes of the past can be undone.'; + let children: React.ReactNode | undefined = void 0; + + if (this.state.action && !this.state.helper) { + children = <> + {(this.state.action && this.state.action !== 'theme' && this.state.action !== 'add-component' && this.state.action !== 'help') && <div className='msp-selection-viewport-controls-actions'> + <ActionMenu header={ActionHeader.get(this.state.action as StructureSelectionModifier)} title='Click to close.' items={this.queries} onSelect={this.selectQuery} noOffset /> + <ActionMenu items={this.helpers} onSelect={this.selectHelper} noOffset /> + </div>} + {this.state.action === 'theme' && <div className='msp-selection-viewport-controls-actions'> + <ControlGroup header='Theme' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleTheme} topRightIcon={CloseSvg}> + <ApplyThemeControls onApply={this.toggleTheme} /> + </ControlGroup> + </div>} + {this.state.action === 'add-component' && <div className='msp-selection-viewport-controls-actions'> + <ControlGroup header='Add Component' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleAddComponent} topRightIcon={CloseSvg}> + <AddComponentControls onApply={this.toggleAddComponent} forSelection /> + </ControlGroup> + </div>} + {this.state.action === 'help' && <div className='msp-selection-viewport-controls-actions'> + <ControlGroup header='Help' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleHelp} topRightIcon={CloseSvg} maxHeight='300px'> + <HelpGroup header='Selection Operations'> + <HelpText>Use <Icon svg={UnionSvg} inline /> <Icon svg={SubtractSvg} inline /> <Icon svg={IntersectSvg} inline /> <Icon svg={SetSvg} inline /> to modify the selection.</HelpText> + </HelpGroup> + <HelpGroup header='Representation Operations'> + <HelpText>Use <Icon svg={BrushSvg} inline /> <Icon svg={CubeOutlineSvg} inline /> <Icon svg={RemoveSvg} inline /> <Icon svg={RestoreSvg} inline /> to color, create components, remove from components, or undo actions.</HelpText> + </HelpGroup> + <ViewportHelpContent selectOnly={true} /> + </ControlGroup> + </div>} + </>; + } else if (ActionHeader.has(this.state.action as any) && this.state.helper === 'residue-list') { + const close = () => this.setState({ action: void 0, helper: void 0 }); + children = <div className='msp-selection-viewport-controls-actions'> + <ControlGroup header='Residue List' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={close} topRightIcon={CloseSvg}> + <ResidueListSelectionHelper modifier={this.state.action as any} plugin={this.plugin} close={close} /> + </ControlGroup> + </div>; + } + return <> <div className='msp-flex-row' style={{ background: 'none' }}> <PureSelectControl title={`Picking Level for selecting and highlighting`} param={StructureSelectionParams.granularity} name='granularity' value={granularity} onChange={this.setGranuality} isDisabled={this.isDisabled} /> @@ -195,7 +266,7 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str <ToggleButton icon={IntersectSvg} title={`${ActionHeader.get('intersect')}. Hold shift key to keep menu open.`} toggle={this.toggleIntersect} isSelected={this.state.action === 'intersect'} disabled={this.isDisabled} /> <ToggleButton icon={SetSvg} title={`${ActionHeader.get('set')}. Hold shift key to keep menu open.`} toggle={this.toggleSet} isSelected={this.state.action === 'set'} disabled={this.isDisabled} /> - <ToggleButton icon={BrushSvg} title='Apply Theme to Selection' toggle={this.toggleTheme} isSelected={this.state.action === 'theme'} disabled={this.isDisabled} style={{ marginLeft: '10px' }} /> + <ToggleButton icon={BrushSvg} title='Apply Theme to Selection' toggle={this.toggleTheme} isSelected={this.state.action === 'theme'} disabled={this.isDisabled} style={{ marginLeft: '10px' }} /> <ToggleButton icon={CubeOutlineSvg} title='Create Component of Selection with Representation' toggle={this.toggleAddComponent} isSelected={this.state.action === 'add-component'} disabled={this.isDisabled} /> <IconButton svg={RemoveSvg} title='Remove/subtract Selection from all Components' onClick={this.subtract} disabled={this.isDisabled} /> <IconButton svg={RestoreSvg} onClick={this.undo} disabled={!this.state.canUndo || this.isDisabled} title={undoTitle} /> @@ -203,30 +274,7 @@ export class StructureSelectionActionsControls extends PluginUIComponent<{}, Str <ToggleButton icon={HelpOutlineSvg} title='Show/hide help' toggle={this.toggleHelp} style={{ marginLeft: '10px' }} isSelected={this.state.action === 'help'} /> <IconButton svg={CancelOutlinedSvg} title='Turn selection mode off' onClick={this.turnOff} /> </div> - {(this.state.action && this.state.action !== 'theme' && this.state.action !== 'add-component' && this.state.action !== 'help') && <div className='msp-selection-viewport-controls-actions'> - <ActionMenu header={ActionHeader.get(this.state.action as StructureSelectionModifier)} title='Click to close.' items={this.queries} onSelect={this.selectQuery} noOffset /> - </div>} - {this.state.action === 'theme' && <div className='msp-selection-viewport-controls-actions'> - <ControlGroup header='Theme' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleTheme} topRightIcon={CloseSvg}> - <ApplyThemeControls onApply={this.toggleTheme} /> - </ControlGroup> - </div>} - {this.state.action === 'add-component' && <div className='msp-selection-viewport-controls-actions'> - <ControlGroup header='Add Component' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleAddComponent} topRightIcon={CloseSvg}> - <AddComponentControls onApply={this.toggleAddComponent} forSelection /> - </ControlGroup> - </div>} - {this.state.action === 'help' && <div className='msp-selection-viewport-controls-actions'> - <ControlGroup header='Help' title='Click to close.' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleHelp} topRightIcon={CloseSvg} maxHeight='300px'> - <HelpGroup header='Selection Operations'> - <HelpText>Use <Icon svg={UnionSvg} inline /> <Icon svg={SubtractSvg} inline /> <Icon svg={IntersectSvg} inline /> <Icon svg={SetSvg} inline /> to modify the selection.</HelpText> - </HelpGroup> - <HelpGroup header='Representation Operations'> - <HelpText>Use <Icon svg={BrushSvg} inline /> <Icon svg={CubeOutlineSvg} inline /> <Icon svg={RemoveSvg} inline /> <Icon svg={RestoreSvg} inline /> to color, create components, remove from components, or undo actions.</HelpText> - </HelpGroup> - <ViewportHelpContent selectOnly={true} /> - </ControlGroup> - </div>} + {children} </>; } } @@ -333,4 +381,34 @@ class ApplyThemeControls extends PurePluginUIComponent<ApplyThemeControlsProps, </Button> </>; } +} + +const ResidueListIdTypeParams = { + idType: ParamDefinition.Select<'auth' | 'label'>('auth', ParamDefinition.arrayToOptions(['auth', 'label'])), + residues: ParamDefinition.Text('', { description: 'A comma separated list of residue ranges in given chain, e.g. A 10-15, B 25, C 30:i' }) +}; + +const DefaultResidueListIdTypeParams = ParamDefinition.getDefaultValues(ResidueListIdTypeParams); + +function ResidueListSelectionHelper({ modifier, plugin, close }: { modifier: StructureSelectionModifier, plugin: PluginContext, close: () => void }) { + const [state, setState] = React.useState(DefaultResidueListIdTypeParams); + + const apply = () => { + if (state.residues.length === 0) return; + + try { + close(); + const query = compileResidueListSelection(state.residues, state.idType); + plugin.managers.structure.selection.fromCompiledQuery(modifier, query, false); + } catch (e) { + plugin.log.error(`Failed to create selection: ${e}`); + } + }; + + return <> + <ParameterControls params={ResidueListIdTypeParams} values={state} onChangeValues={setState} onEnter={apply} /> + <Button className='msp-btn-commit msp-btn-commit-on' disabled={state.residues.length === 0} onClick={apply} style={{ marginTop: '1px' }}> + {capitalize(modifier)} Selection + </Button> + </>; } \ No newline at end of file diff --git a/src/mol-script/util/residue-list.ts b/src/mol-script/util/residue-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa509e9a7cd8c2475a561b7e94974effaf40b4dc --- /dev/null +++ b/src/mol-script/util/residue-list.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { StructureQuery } from '../../mol-model/structure/query'; +import { Expression } from '../language/expression'; +import { MolScriptBuilder as MS } from '../language/builder'; +import { compile } from '../runtime/query/base'; + +// TODO: make this into a separate "language"? + +type ResidueListSelectionEntry = + | { kind: 'single', asym_id: string; seq_id: number; ins_code?: string } + | { kind: 'range', asym_id: string; seq_id_beg: number; seq_id_end: number; } + +function entriesToQuery(xs: ResidueListSelectionEntry[], kind: 'auth' | 'label') { + const groups: Expression[] = []; + + const asym_id_key = kind === 'auth' ? 'auth_asym_id' as const : 'label_asym_id' as const; + const seq_id_key = kind === 'auth' ? 'auth_seq_id' as const : 'label_seq_id' as const; + + for (const x of xs) { + if (x.kind === 'range') { + groups.push(MS.struct.generator.atomGroups({ + 'chain-test': MS.core.rel.eq([MS.ammp(asym_id_key), x.asym_id]), + 'residue-test': MS.core.rel.inRange([MS.ammp(seq_id_key), x.seq_id_beg, x.seq_id_end]) + })); + } else { + const ins_code = (x.ins_code ?? '').trim(); + + groups.push(MS.struct.generator.atomGroups({ + 'chain-test': MS.core.rel.eq([MS.ammp(asym_id_key), x.asym_id]), + 'residue-test': MS.core.logic.and([ + MS.core.rel.eq([MS.ammp(seq_id_key), x.seq_id]), + MS.core.rel.eq([MS.ammp('pdbx_PDB_ins_code'), ins_code]) + ]) + })); + } + } + + const query = MS.struct.combinator.merge(groups); + + return compile(query) as StructureQuery; +} + +function parseRange(c: string, s: string[], e: number): ResidueListSelectionEntry | undefined { + if (!c || s.length === 0 || Number.isNaN(+s[0])) return; + if (Number.isNaN(e)) { + return { kind: 'single', asym_id: c, seq_id: +s[0], ins_code: s[1] }; + } + return { kind: 'range', asym_id: c, seq_id_beg: +s[0], seq_id_end: e }; +} + +function parseInsCode(e?: string) { + if (!e) return []; + return e.split(':'); +} + +function parseResidueListSelection(input: string): ResidueListSelectionEntry[] { + return input.split(',') // A 1-3, B 3 => [A 1-3, B 3] + .map(e => e.trim().split(/\s+|[-]/g).filter(e => !!e)) // [A 1-3, B 3] => [[A, 1, 3], [B, 3]] + .map(e => parseRange(e[0], parseInsCode(e[1]), +e[2])) + .filter(e => !!e) as ResidueListSelectionEntry[]; +} + +// parses a list of residue ranges, e.g. A 10-100, B 30, C 12:i +export function compileResidueListSelection(input: string, idType: 'auth' | 'label') { + const entries = parseResidueListSelection(input); + return entriesToQuery(entries, idType); +} \ No newline at end of file