Source code for schrodinger.application.msv.gui.homology_modeling.steps

import inflect

from schrodinger.application.msv.gui import gui_models
from schrodinger.application.msv.gui.dialogs import OptimizeAlignmentDialog
from schrodinger.application.msv.gui.homology_modeling import constants
from schrodinger.application.msv.gui.homology_modeling import hm_models
from schrodinger.application.msv.gui.homology_modeling import \
    homology_modeling_step_ui
from schrodinger.application.msv.gui.homology_modeling import \
    homology_multiple_view_tabs_ui
from schrodinger.application.msv.gui.homology_modeling import ligand_dialog
from schrodinger.application.msv.gui.homology_modeling import settings_dialog
from schrodinger.application.msv.gui.homology_modeling import view_tab_table
from schrodinger.application.msv.gui.homology_modeling.constants import Mode
from schrodinger.application.msv.gui.homology_modeling.constants import \
    StepAction
from schrodinger.application.msv.gui.homology_modeling.hm_models import \
    get_seq_display_name
from schrodinger.application.msv.gui.menu import EnabledTargetSpec
from schrodinger.models import mappers
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import mapperwidgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.mapperwidgets import EnumComboBox
from schrodinger.ui.qt.mapperwidgets import plptable
from schrodinger.ui.qt.utils import wrap_qt_tag

step_stylesheet = """
QWidget {
    font-size: 12px;
}
QCheckBox#step_cb {
    font-weight: bold;
}
QCheckBox#step_cb:!enabled {
    color: #999;
}
QCheckBox#step_cb::indicator {
    width: 24px;
    height: 24px;
    padding-right: 5px;
}

QLabel#detail_lbl {
    color: #666;
    font-size: 11px;
    font-style: italic;
}
QLabel#detail_lbl:!enabled {
    color: #aaa;
}

QToolButton#info_btn {
    image: url(:/msv/icons/info_icon.png);
    /* NOTE: image does not appear without border style */
    border: 0px;
}
QToolButton#info_btn::hover{
    image: url(:/msv/icons/info_icon-h.png);
}

QPushButton#action_btn {
    border: 0px;
    text-decoration: underline;
    color: #235fa3;
}

QPushButton#action_btn:!enabled {
    border: 0px;
    text-decoration: none;
    color: #ccc;
}
"""

workflow_additional_stylesheet = """
QLabel#instruction_lbl {
    font-size: 11px;
}
QLabel#target_name_lbl, QLabel#identity_lbl {
    color: #76a459;
    font-style: italic;
}

QLabel#template_name_lbl {
    color: #964f76;
    font-style: italic;
}
QToolButton#optimize_btn {
    padding: 0px;
    width: 20px;
    height: 20px;
    image: url(:/msv/icons/optimize.png);
    /* NOTE: image does not appear without border style */
    border: 0px;
}
QToolButton#optimize_btn:!enabled {
    image: url(:/msv/icons/optimize-d.png);
}
QToolButton#optimize_btn::hover {
    image: url(:/msv/icons/optimize-h.png);
}
QToolButton#optimize_btn::pressed {
    image: url(:/msv/icons/optimize-clicked.png);
}

QCheckBox#step_cb::indicator {
    image: url(:/msv/icons/chevron-active.png);
}
QCheckBox#step_cb::indicator:!enabled {
    image: url(:/msv/icons/chevron-disabled.png);
}

QCheckBox#step_cb::indicator[status="QUESTIONABLE"] {
    image: url(:/msv/icons/warning-question.png);
}
QLabel#identity_lbl[status="QUESTIONABLE"] {
    font-weight: bold;
    color: #c7a049;
}

QCheckBox#step_cb::indicator[status="FIXABLE"] {
    image: url(:/msv/icons/error-question.png);
}
QLabel#identity_lbl[status="FIXABLE"], QLabel#template_name_lbl[status="UNACCEPTABLE"] {
    font-weight: bold;
    color: #ab692c;
}

QCheckBox#step_cb::indicator[status="UNACCEPTABLE"] {
    image: url(:/msv/icons/error-exclamation.png);
}

QCheckBox#step_cb::indicator[status="ACCEPTABLE"] {
    image: url(:/msv/icons/check-proposed.png);
}
/* Put most specific selectors at the bottom */
QCheckBox#step_cb::indicator:checked[status="ACCEPTABLE"] {
    image: url(:/msv/icons/check-approved.png);
}
QCheckBox#step_cb::indicator:checked!enabled[status="ACCEPTABLE"] {
    image: url(:/msv/icons/check-approved-disabled.png);
}

"""

ligands_additional_stylesheet = """
QCheckBox#step_cb::indicator {
    image: url(:/msv/icons/toggle-OFF-light.png);
}
QCheckBox#step_cb::indicator:checked {
    image: url(:/msv/icons/toggle-ON-light-2.png);
}
QCheckBox#step_cb::indicator:hover{
    image: url(:/msv/icons/toggle-hover-light.png);
}
QCheckBox#step_cb::indicator:!enabled {
    image: url(:/msv/icons/toggle-disabled-light.png);
}
QLabel#num_ligs_lbl {
    color: #666666;
    font-style: italic;
}
QLabel#num_ligs_lbl[status="QUESTIONABLE"] {
    font-weight: bold;
    color: #c7a049;
}
"""

settings_additional_stylesheet = """
QCheckBox#step_cb::indicator {
    image: url(:/msv/icons/settings-light.png);
}
QCheckBox#step_cb::indicator[highlight=true] {
    image: url(:/msv/icons/settings-custom-light.png);
}
QCheckBox#step_cb::indicator:!enabled {
    image: url(:/msv/icons/settings_disabled-light.png);
}
QCheckBox#step_cb::indicator:hover {
    image: url(:/msv/icons/settings-hover-light.png);
}
"""

heteromultimer_stylesheet = """
QWidget {
    font-size: 12px;
}
QLabel#create_details_lbl, QLabel#distribute_details_lbl {
    color: #666;
    font-size: 11px;
}
QLabel#distribute_details_lbl {
    font-style: italic;
}
QCheckBox#distribute_chains_cb, QCheckBox#select_tabs_cb {
    font-weight: bold;
}
QCheckBox#distribute_chains_cb::indicator, QCheckBox#select_tabs_cb::indicator {
    image: url(:/msv/icons/chevron-active.png);
    width: 24px;
    height: 24px;
    padding-right: 5px;
}
QToolButton#info_btn {
    image: url(:/msv/icons/info_icon.png);
    border: 0px;
}
QToolButton#info_btn::hover{
    image: url(:/msv/icons/info_icon-h.png);
    border: 0px;
}
"""

# Reduce the paragraph spacing.
tooltip_style = """
<style>
p {
    margin-top: 5px;
    margin-bottom: 5px;
}
</style>
"""
STRUCTALIGN_REF_PROP = 's_psp_StructAlign_Reference'
STRUCTALIGN_RMSD_PROP = 'r_psp_StructAlign_RMSD'


class _ActionMixin:
    """
    Mixin for adding actions to an AbstractStep.

    Child classes must define `ACTIONS` and may define `action_signals` to
    define signals to emit on button click.

    :cvar ACTIONS: Enum for action buttons
    :vartype ACTIONS: tuple[constants.StepAction]

    :ivar action_btns: Mapping of action enum member to action button
        (automatically generated from `ACTIONS` - button text is enum value)
    :vartype action_btns: dict[constants.StepAction, QtWidgets.QPushButton]

    :ivar action_signals: Mapping between StepAction and signal to emit on
        button click. Defaults to None, so buttons will not emit a signal.
    :vartype action_signals: dict[constants.StepAction, QtCore.pyqtSignal]

    :cvar ENABLE_ON_CHECK: Enabled state for actions when the checkbox is
        checked. None does not affect the actions. Defaults to False (i.e.
        disable actions when checkbox is checked, enable when unchecked)
    :vartype ENABLE_ON_CHECK: bool or NoneType
    """

    ACTIONS = NotImplemented
    ENABLE_ON_CHECK = False

    def initSetUp(self):
        super().initSetUp()
        self.action_signals = None
        self.action_btns = {
            enum_member: QtWidgets.QPushButton(enum_member.value)
            for enum_member in self.ACTIONS
        }
        for btn in self.action_btns.values():
            btn.setObjectName("action_btn")
        self.ui.step_cb.toggled.connect(self._onCbToggled)

    def initLayOut(self):
        super().initLayOut()
        for btn in self.action_btns.values():
            self.ui.action_layout.addWidget(btn)

    def initFinalize(self):
        super().initFinalize()
        if self.action_signals is not None:
            for action, signal in self.action_signals.items():
                btn = self.action_btns[action]
                btn.clicked.connect(signal)

    def _onCbToggled(self):
        """
        If `ENABLE_ON_CHECK` is True, change the action button enabled state to
        match the checkbox check state (i.e. enabled if checked).
        If `ENABLE_ON_CHECK` is False, change the action button enabled state
        to be the opposite of the checkbox check state (i.e. disabled if
        checked).
        """
        if self.ENABLE_ON_CHECK is None:
            return
        checked = self.ui.step_cb.isChecked()
        enable = checked is self.ENABLE_ON_CHECK
        for btn in self.action_btns.values():
            btn.setEnabled(enable)


class _ToolTipMixin:
    """
    Mixin to show tooltip when clicking `self.ui.info_btn`
    """
    TOOLTIP = ""

    def initSetUp(self):
        super().initSetUp()
        self.ui.info_btn.setToolTip(
            "Click for more information about this step.")
        self.ui.info_btn.clicked.connect(self.showToolTip)

    def _getToolTipText(self):
        """
        Get the tooltip text after applying the style.
        """
        return f"{tooltip_style}{self.TOOLTIP}"

    def showToolTip(self):
        text = wrap_qt_tag(self._getToolTipText())
        QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), text, self.ui.info_btn)


[docs]class AbstractStep(_ToolTipMixin, mappers.MapperMixin, basewidgets.BaseWidget): """ Abstract class for a homology modeling step. Subclasses must define `_onPairsChanged`, which is called with target_template_pairs :cvar TEXT: The text for the checkbox :cvar DETAIL_TEXT: The detail text :cvar EXTRA_STYLESHEET: Additional stylesheet to append :cvar TOOLTIP: The text to show on clicking on info button """ model_class = hm_models.HomologyModelingInput ui_module = homology_modeling_step_ui TEXT = "" DETAIL_TEXT = "" EXTRA_STYLESHEET = "" TOTAL_WIDTH = 300
[docs] def initSetUp(self): super().initSetUp() self.setStyleSheet(step_stylesheet + self.EXTRA_STYLESHEET) self._font_metrics = QtGui.QFontMetrics(self.font()) self._text_width = (self.TOTAL_WIDTH - self._font_metrics.horizontalAdvance(self.TEXT)) self._status_lbl = None self.widgets_with_status = [] self.ui.step_cb.setText(self.TEXT) self.ui.step_cb.setCheckable(False) self.ui.detail_lbl.setText(self.DETAIL_TEXT)
[docs] def initLayOut(self): super().initLayOut() if self._status_lbl is not None: self.ui.status_layout.addWidget(self._status_lbl) self.widgets_with_status.append(self._status_lbl)
[docs] def defineMappings(self): M = self.model_class pairs_tgt = mappers.TargetSpec(setter=self._onPairsChanged) return [ (pairs_tgt, M.target_template_pairs), ]
def _elideHtml(self, richtext): """ Elide text that may contain HTML based on the plaintext width. HTML will be stripped if eliding to avoid partial HTML tags. """ plaintext = QtGui.QTextDocumentFragment.fromHtml(richtext).toPlainText() elided_text = self._font_metrics.elidedText(plaintext, QtCore.Qt.ElideRight, self._text_width) if len(elided_text) == len(plaintext): # If text does not need to be elided, preserve HTML return richtext return elided_text
[docs] def connectToWorkflow(self, workflow): """ Connect this step to the correct workflow instance variables and signals. May override for custom behavior. :param workflow: The parent workflow to link the signals to :type workflow: AbstractWorkflow """
[docs] def setStatusText(self, text): """ Set text to the status label """ if self._status_lbl is None: return tooltip = text if text: text = self._elideHtml(text) self._status_lbl.setText(text) self._status_lbl.setToolTip(tooltip)
[docs] def setStatus(self, status=None): """ :param status: Status to set on step or None to reset :type status: constants.StepState or NoneType """ if status is not None: status = status.name for widget in self.widgets_with_status: widget.setProperty("status", status) qt_utils.update_widget_style(widget)
def _onPairsChanged(self, target_template_pairs): """ Must be reimplemented in subclasses to update the view based on the new pairs :param target_template_pairs: List of objects representing a (target sequence, template sequence) pair :type target_template_pairs: list[hm_models.TargetTemplatePair] """ raise NotImplementedError
[docs] def makeInitialModel(self): """ @overrides: MapperMixin We just use `None` as our initial model as a performance optimization. Steps never need their own model in isolation anyways. """ return None
[docs]class AbstractActionStep(_ActionMixin, AbstractStep): """ Class for steps where the corresponding action cannot be performed in MSV2 """
[docs]class AbstractWorkflowStep(AbstractStep): """ Class for homology modeling steps that are part of the workflow """ EXTRA_STYLESHEET = workflow_additional_stylesheet
[docs] def initSetUp(self): super().initSetUp() self.widgets_with_status.append(self.ui.step_cb)
[docs] def setStatus(self, status=None): super().setStatus(status) self.ui.step_cb.setCheckable(status is constants.StepState.ACCEPTABLE)
def _getToolTipText(self): """ If the step is checked, show the completed info tooltip on click """ # overrides _ToolTipMixin completed = self.ui.step_cb.isChecked() if completed: return f"{tooltip_style}{constants.COMPLETED_TOOLTIP}" return super()._getToolTipText()
class _AbstractSequenceStep(_ActionMixin, AbstractWorkflowStep): def initSetUp(self): super().initSetUp() self._status_lbl = QtWidgets.QLabel() def _onPairsChanged(self, target_template_pairs): """ @overrides: AbstractStep """ self._updateSequenceName(target_template_pairs) def _updateSequenceName(self, target_template_pairs): """ Updates the appropriate name label when the target_template_pairs change """ name = self._getSequenceName(target_template_pairs) if name is None: name = "" if name == "": status = None else: status = constants.StepState.ACCEPTABLE self.setStatusText(name) self.setStatus(status) def _getSequenceName(self, target_template_pairs): """ Returns the name of the sequence(s) """ raise NotImplementedError()
[docs]class TargetSequenceStep(_AbstractSequenceStep): TEXT = "Get the target sequence" DETAIL_TEXT = "Must be first (Reference) sequence" ACTIONS = (StepAction.IMPORT_SEQUENCE,) TOOLTIP = constants.TARGET_SEQ_STEP_TT importSeqAsRefRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self._status_lbl.setObjectName("target_name_lbl") self.action_signals = { StepAction.IMPORT_SEQUENCE: self.importSeqAsRefRequested }
[docs] def connectToWorkflow(self, workflow): # @overrides: AbstractStep workflow.target_step = self self.importSeqAsRefRequested.connect(workflow.importSeqAsRefRequested)
def _getSequenceName(self, pairs): """ Returns the first target seq name. """ if len(pairs) > 0: target_seq = pairs[0].target_seq if target_seq is not None: return get_seq_display_name(target_seq)
[docs]class BatchTargetSequenceStep(TargetSequenceStep): TEXT = "Get 1st target" DETAIL_TEXT = "Set as Reference for finding template" TOOLTIP = constants.BATCH_TARGET_SEQ_STEP_TT
[docs]class OtherTargetsStep(TargetSequenceStep): TEXT = "Get other targets" DETAIL_TEXT = "Targets must be below reference (all or selected)" ACTIONS = (StepAction.IMPORT_SEQUENCES,) TOOLTIP = constants.OTHER_TARGET_STEP_TT importSeqRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self.action_signals = { StepAction.IMPORT_SEQUENCES: self.importSeqRequested }
def _getSequenceName(self, target_template_pairs): """ There are expected to be too many names to show so just returns the number of sequences """ num_pairs = len(target_template_pairs) if num_pairs > 1: return f"({num_pairs} sequences)"
[docs]class TemplateStructureStep(_AbstractSequenceStep): TEXT = "Specify a template structure" DETAIL_TEXT = "Usually second sequence" ACTIONS = (StepAction.FIND_HOMOLOGS, StepAction.IMPORT_HOMOLOGS) TOOLTIP = constants.TEMP_ST_STEP_TT findHomologsRequested = QtCore.pyqtSignal() importHomologsRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self._status_lbl.setObjectName("template_name_lbl") self.action_signals = { StepAction.FIND_HOMOLOGS: self.findHomologsRequested, StepAction.IMPORT_HOMOLOGS: self.importHomologsRequested }
[docs] def initLayOut(self): super().initLayOut() layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.action_btns[StepAction.IMPORT_HOMOLOGS]) layout.addWidget(QtWidgets.QLabel(' <i>or</i> ')) layout.addWidget(self.action_btns[StepAction.FIND_HOMOLOGS]) self.ui.action_layout.insertLayout(0, layout)
[docs] def connectToWorkflow(self, workflow): # @overrides: AbstractStep workflow.template_step = self self.findHomologsRequested.connect(workflow.findHomologsRequested) self.importHomologsRequested.connect( lambda: workflow.importHomologsRequested.emit(False))
def _getSequenceName(self, pairs): """ Returns the first template seq name. """ if len(pairs) > 0: template_seq = pairs[0].template_seq if template_seq is not None: return get_seq_display_name(template_seq)
[docs]class BatchTemplateStructureStep(TemplateStructureStep): TEXT = "Specify template structure" DETAIL_TEXT = "Once in tab, template must be set as <b>Reference</b> for modeling" TOOLTIP = constants.BATCH_TEMP_ST_STEP_TT setAsReferenceRequested = QtCore.pyqtSignal() ACTIONS = (StepAction.FIND_HOMOLOGS, StepAction.IMPORT_HOMOLOGS, StepAction.SET_AS_REFERENCE)
[docs] def initSetUp(self): super().initSetUp() self.action_signals = { StepAction.FIND_HOMOLOGS: self.findHomologsRequested, StepAction.IMPORT_HOMOLOGS: self.importHomologsRequested, StepAction.SET_AS_REFERENCE: self.setAsReferenceRequested, }
def _onPairsChanged(self, pairs): super()._onPairsChanged(pairs) status = None num_pairs = len(pairs) if num_pairs > 0: pair = pairs[0] if pair.template_seq is not None: status = constants.StepState.ACCEPTABLE elif num_pairs > 1: status = constants.StepState.UNACCEPTABLE self.setStatus(status) ref_enabled = status is not constants.StepState.ACCEPTABLE self.action_btns[StepAction.SET_AS_REFERENCE].setEnabled(ref_enabled) def _getSequenceName(self, pairs): name = super()._getSequenceName(pairs) if name is None and len(pairs) > 1: return "Ref lacks structure" return name
[docs]class MultipleTemplateStructuresStep(TemplateStructureStep): TEXT = "Specify template structures" DETAIL_TEXT = "Must be second and later sequences" TOOLTIP = constants.MULTI_TEMP_ST_STEP_TT def _getSequenceName(self, target_template_pairs): """ Returns all of the template seq names. """ names = [] for pair in target_template_pairs: template_seq = pair.template_seq if template_seq is not None: names.append(get_seq_display_name(template_seq)) return ", ".join(names)
[docs] def connectToWorkflow(self, workflow): # @overrides: TemplateStructuresStep workflow.template_step = self self.findHomologsRequested.connect(workflow.findHomologsRequested) self.importHomologsRequested.connect( lambda: workflow.importHomologsRequested.emit(True))
[docs]class HomomultimerTemplateStructuresStep(MultipleTemplateStructuresStep): TEXT = "Find template chains"
[docs]class BaseAlignSequencesStep(_ActionMixin, AbstractWorkflowStep): TEXT = "Align sequences" DETAIL_TEXT = "Run if not auto-aligned when template was imported" ACTIONS = (StepAction.RUN_ALIGNMENT,) TOOLTIP = constants.BASE_ALIGN_SEQ_STEP_TT alignSeqsRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self._status_lbl = QtWidgets.QLabel() self._status_lbl.setObjectName("identity_lbl") self.action_signals = { StepAction.RUN_ALIGNMENT: self.alignSeqsRequested }
[docs] def connectToWorkflow(self, workflow): # @overrides: AbstractStep workflow.align_step = self self.alignSeqsRequested.connect(workflow.alignSeqsRequested)
def _onPairsChanged(self, pairs): # @overrides: AbstractStep self._updateIdentity(pairs) def _updateIdentity(self, pairs): self._updateIdentityProperties(pairs) text = self._getIdentityText(pairs) self.setStatusText(text) def _getIdentityText(self, pairs): identities = [] for pair in pairs: if pair.identity is None: continue id_text = f"{pair.identity:.0%}" if pair.alignment_quality is not constants.AlignmentQuality.ACCEPTABLE: id_text = f"<b>{id_text}</b>" identities.append(id_text) text = ", ".join(identities) num_identities = len(identities) if num_identities: id_text = inflect.engine().plural("ID", num_identities) text = f"{id_text} {text}" return text def _updateIdentityProperties(self, pairs): min_quality = hm_models.get_min_alignment_quality(pairs) if min_quality is constants.AlignmentQuality.WEAK: status = constants.StepState.FIXABLE elif min_quality is constants.AlignmentQuality.LOW: status = constants.StepState.QUESTIONABLE elif min_quality is constants.AlignmentQuality.ACCEPTABLE: status = constants.StepState.ACCEPTABLE else: status = None self.setStatus(status)
[docs]class AlignSequencesStep(BaseAlignSequencesStep): """ Sequence alignment step for simple (one-one) homology modeling """ optimizeAlignmentRequested = QtCore.pyqtSignal(str, str, float)
[docs] def initSetOptions(self): super().initSetOptions() self.TOOLTIP = self.TOOLTIP + constants.ALIGN_SEQ_STEPS_TT
[docs] def initSetUp(self): super().initSetUp() self._optimize_btn = QtWidgets.QToolButton() self._optimize_btn.setObjectName("optimize_btn") self._optimize_btn.setToolTip( wrap_qt_tag(""" <span style="color: #888888; font-weight: bold; font-style: italic;"> Optional step:</span><br/> Click to optimize alignment at binding site after full sequence alignment""")) self._optimize_btn.setEnabled(False) self._optimize_btn.setVisible(False) self._optimize_dlg = OptimizeAlignmentDialog(self) self._optimize_dlg.optimizeAlignmentRequested.connect( self._onOptimizeAlignmentRequested) self._optimize_btn.clicked.connect(self._runOptimizeDialog)
[docs] def initLayOut(self): super().initLayOut() self.ui.status_layout.insertWidget(0, self._optimize_btn)
def _onPairsChanged(self, pairs): """ @overrides: AbstractStep """ super()._onPairsChanged(pairs) can_optimize = False if pairs: pair = pairs[0] can_optimize = bool( pair.is_valid and pair.alignment_quality > constants.AlignmentQuality.WEAK) self._optimize_btn.setEnabled(can_optimize) self._optimize_btn.setVisible(can_optimize) def _runOptimizeDialog(self): template_seq = self.model.target_template_pairs[0].template_seq sel_residues = self.model._aln.res_selection_model.getSelection() self._optimize_dlg.setResidues(template_seq, sel_residues) ligmols = self.model.settings.ligand_dlg_model.ligands self._optimize_dlg.model.ligands = ligmols self._optimize_dlg.resize(1, 1) self._optimize_dlg.exec() def _onOptimizeAlignmentRequested(self, ligand, residue_asl, radius): if ligand is None: ligand_asl = "" else: # Select ligand for inclusion in model lig_model = self.model.settings.ligand_dlg_model if ligand not in lig_model.selected_ligands: for other_lig in lig_model.ligands: if other_lig == ligand: lig_model.selected_ligands.append(other_lig) break asls = (lig.asl for lig in ligand.source_ligands) ligand_asl = " OR ".join(asls) self.optimizeAlignmentRequested.emit(ligand_asl, residue_asl, radius)
[docs]class BatchAlignSequencesStep(BaseAlignSequencesStep): DETAIL_TEXT = "Align all targets to template" TOOLTIP = constants.BATCH_ALIGN_SEQ_STEP_TT
[docs]class AlignStructuresSequencesStep(BaseAlignSequencesStep): TEXT = "Align structures && sequences" # Need to escape ampersand for btn DETAIL_TEXT = "Align to first template then to target sequence" ACTIONS = (StepAction.ALIGN_STRUCTURES, StepAction.ALIGN_SEQUENCES) TOOLTIP = constants.ALIGN_ST_SEQ_STE_TT alignStructsRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self.action_signals = { StepAction.ALIGN_STRUCTURES: self.alignStructsRequested, StepAction.ALIGN_SEQUENCES: self.alignSeqsRequested, }
def _getIdentityText(self, pairs): """ @overrides: BaseAlignSequencesStep """ if not pairs: return "" text = super()._getIdentityText(pairs) if text != "": text = f"Seq {text}" all_rmsds = [] templates = [ pair.template_seq for pair in pairs if pair.template_seq is not None ] if not templates: return ref_seq, *other_seqs = templates ref_name = get_seq_display_name(ref_seq) for seq in other_seqs: st = seq.getStructure() if st.property.get(STRUCTALIGN_REF_PROP) == ref_name: rmsd = st.property.get(STRUCTALIGN_RMSD_PROP) if rmsd is not None: all_rmsds.append(rmsd) rmsd_text = ", ".join(f"{rmsd:.2f}" for rmsd in all_rmsds) if rmsd_text: rmsd_noun = inflect.engine().plural("RMSD", len(all_rmsds)) text = f"{rmsd_noun} {rmsd_text}; {text}" return text
[docs]class HomomultimerAlignSequencesStep(BaseAlignSequencesStep): TOOLTIP = constants.HOMOMULTIMER_ALIGN_SEQ_STEP_TT
[docs]class RegionsStep(_ActionMixin, AbstractWorkflowStep): TEXT = "Define regions on templates" DETAIL_TEXT = ("Second sequence is default template; select regions on " "other sequences") ACTIONS = (StepAction.PICK,) TOOLTIP = constants.REGION_STEP_TT
[docs] def initSetUp(self): super().initSetUp() self.num_regions_lbl = QtWidgets.QLabel() pick_btn = self.action_btns[StepAction.PICK] pick_btn.setObjectName("pick_btn") pick_btn.setCheckable(True) self.pick_btn = pick_btn
[docs] def initLayOut(self): super().initLayOut() self.ui.status_layout.addWidget(self.num_regions_lbl)
[docs] def defineMappings(self): mappings = super().defineMappings() M = self.model_class regions_tgt = mappers.TargetSpec(setter=self._onRegionsChanged) mappings += [ (regions_tgt, M.composite_regions), (self.pick_btn, M.pick_chimera), ] # yapf: disable return mappings
def _onPairsChanged(self, pairs): """ @overrides: AbstractStep Intentional no-op because regions view does not need to update when pairs change """ pass def _onRegionsChanged(self, regions): num_regions = len(regions) if num_regions > 0: status = constants.StepState.ACCEPTABLE else: num_regions = "no" status = None alternate_text = inflect.engine().plural("alternate", num_regions) text = f"({num_regions} {alternate_text})" self.num_regions_lbl.setText(text) self.setStatus(status) def _onCbToggled(self): """ @overrides: _ActionMixin Uncheck the pick_btn if it becomes disabled """ super()._onCbToggled() if not self.pick_btn.isEnabled(): self.pick_btn.setChecked(False)
[docs]class OptionalMixin: """ Mixin to add non-bold text (optional) after the step name """
[docs] def initSetUp(self): super().initSetUp() self._optional_lbl = QtWidgets.QLabel(" (optional)")
[docs] def initLayOut(self): super().initLayOut() # layout item 0 is the step title self.ui.cb_layout.insertWidget(1, self._optional_lbl)
[docs]class LigandsStep(OptionalMixin, AbstractActionStep): TEXT = "Include ligands && cofactors" DETAIL_TEXT = ("Check to preserve hets; optionally choose waters, " "constrain proximity") ACTIONS = (StepAction.CHOOSE_LIGANDS,) EXTRA_STYLESHEET = ligands_additional_stylesheet ENABLE_ON_CHECK = True TOOLTIP = constants.LIGANDS_STEP_TT removeLigandConstraintsRequested = QtCore.pyqtSignal() ligandDialogClosed = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self._status_lbl = QtWidgets.QLabel() self._status_lbl.setObjectName("num_ligs_lbl") self._warning_lbl = QtWidgets.QLabel() warning_pixmap = QtGui.QPixmap(':/msv/icons/error-exclamation.png') warn_height = self._font_metrics.height() + 4 self._warning_lbl.setPixmap(warning_pixmap.scaledToHeight(warn_height)) self._warning_lbl.setVisible(False) self.ui.step_cb.setCheckable(True) self.ligand_dlg = ligand_dialog.LigandCofactorDialog(parent=self) self.ligand_dlg.energyBasedModelingRequested.connect( self._setEnergyBasedModeling) self.ligand_dlg.removeConstraintsRequested.connect( self.removeLigandConstraintsRequested) self.ligand_pick_btn = self.ligand_dlg.ui.pick_constraints_btn self.action_btns[StepAction.CHOOSE_LIGANDS].clicked.connect( self._showLigandDialog)
[docs] def initLayOut(self): super().initLayOut() self.ui.status_layout.insertWidget(0, self._warning_lbl)
[docs] def defineMappings(self): M = self.model_class settings = M.settings mappings = super().defineMappings() mappings += [ (self.ui.step_cb, settings.include_hets), (self.ligand_dlg, M.settings.ligand_dlg_model), (self._updateNumLigands, settings.include_hets), (self._updateNumLigands, M.settings.ligand_dlg_model.selected_ligands), (self._updateNumLigands, M.settings.ligand_dlg_model.include_waters), ] # yapf: disable return mappings
[docs] def connectToWorkflow(self, workflow): # @overrides: AbstractStep workflow.ligands_step = self workflow.ligand_pick_btn = self.ligand_pick_btn self.removeLigandConstraintsRequested.connect( workflow.removeLigandConstraintsRequested) self.ligandDialogClosed.connect(workflow.ligandDialogClosed)
def _onPairsChanged(self, target_template_pairs): """ @overrides: AbstractStep """ # Don't need to update view - ligand dialog model is updated by the task def _updateNumLigands(self): """ Show the number of selected ligand molecules. """ if self.model.settings.include_hets: include_waters = self.model.settings.ligand_dlg_model.include_waters ligands = self.model.settings.ligand_dlg_model.selected_ligands else: include_waters = False ligands = [] num_cofactors = 0 for ligmol in ligands: if any(lig.cofactor for lig in ligmol.source_ligands): num_cofactors += 1 num_ligands = len(ligands) - num_cofactors status = None if num_ligands <= 1 else constants.StepState.QUESTIONABLE self._warning_lbl.setVisible(status is constants.StepState.QUESTIONABLE) text_parts = [] if num_ligands: if num_cofactors and include_waters: # Don't need to conditionally pluralize because min number is 2 lig_text = f"{num_ligands + num_cofactors} hets" else: lig_word = inflect.engine().plural("ligand", num_ligands) lig_text = f"{num_ligands} {lig_word}" text_parts.append(lig_text) if num_cofactors and not (num_ligands and include_waters): cofactor_word = inflect.engine().plural("cofactor", num_cofactors) cofactor_text = f"{num_cofactors} {cofactor_word}" text_parts.append(cofactor_text) tt_parts = [] for ligmol in ligands: tt_parts.append("-".join( lig.pdbres.strip() for lig in ligmol.source_ligands)) tt_text = ", ".join(tt_parts) if include_waters: text_parts.append("waters") water_tt = "all waters" if len(ligands): water_tt = " and " + water_tt if len(ligands) > 1: water_tt = "," + water_tt # Oxford comma tt_text += water_tt self.setStatusText(", ".join(text_parts)) # Override default status tooltip self._status_lbl.setToolTip(tt_text) self.setStatus(status) def _showLigandDialog(self): self.ligand_dlg.run(finished_callback=self.ligandDialogClosed.emit) def _setEnergyBasedModeling(self): prime_settings = self.model.settings.prime_settings prime_settings.prime_method = constants.PrimeMethod.ENERGY
[docs]class MultipleTemplatesLigandStep(LigandsStep): TOOLTIP = constants.MULTI_TEMP_LIGS_STEP_TT
[docs]class SettingsStep(OptionalMixin, AbstractActionStep): model_class = hm_models.HomologyModelingSettings TEXT = "Change model settings" TOOLTIP = constants.SETTNGS_STEP_TT ACTIONS = (StepAction.VIEW_SETTINGS,) EXTRA_STYLESHEET = settings_additional_stylesheet removeResidueConstraintsRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self.settings_dlg = settings_dialog.SettingsDialog(parent=self) self.settings_dlg.removeResidueConstraintsRequested.connect( self.removeResidueConstraintsRequested) self.ui.step_cb.clicked.connect(self._showSettingsDialog) self.action_btns[StepAction.VIEW_SETTINGS].clicked.connect( self._showSettingsDialog)
[docs] def defineMappings(self): """ @overrides: AbstractStep Intentionally skipping parent mappings because model class is different """ M = self.model_class return [ (self.settings_dlg, M), (mappers.TargetSpec(setter=self._updateDisplay), M), ]
[docs] def getSignalsAndSlots(self, model): """ @overrides: AbstractStep Intentionally skipping parent slots because model class is different """ return []
[docs] def connectToWorkflow(self, workflow): # @overrides: AbstractStep workflow.settings_step = self self.removeResidueConstraintsRequested.connect( workflow.removeResidueConstraintsRequested)
def _updateDisplay(self, settings): prime_settings = settings.prime_settings self.ui.step_cb.setProperty('highlight', not prime_settings.isDefault()) qt_utils.update_widget_style(self.ui.step_cb) summary_text = f"Currently: {prime_settings.getSummaryText()}" self.ui.detail_lbl.setText(summary_text) def _showSettingsDialog(self): self.settings_dlg.run()
[docs]class AbstractWorkflow(mappers.MapperMixin, basewidgets.BaseWidget): """ Abstract widget containing a sequence of homology modeling `AbstractStep`. Subclasses must override `STEP_CLASSES`. Subclasses with custom step types should extend `_enableReachableSteps()` to enable those steps when needed. :cvar STEP_CLASSES: Classes of the steps :vartype STEP_CLASSES: list[AbstractStep] """ model_class = hm_models.HomologyModelingInput importSeqAsRefRequested = QtCore.pyqtSignal() findHomologsRequested = QtCore.pyqtSignal() importHomologsRequested = QtCore.pyqtSignal(bool) alignSeqsRequested = QtCore.pyqtSignal() removeLigandConstraintsRequested = QtCore.pyqtSignal() removeResidueConstraintsRequested = QtCore.pyqtSignal() ligandDialogClosed = QtCore.pyqtSignal() STEP_CLASSES = [] SINGLE_TEMPLATE = False INSTRUCTION_TEXT = ( 'Choose links below as needed to prepare for model creation.\n' 'Click the "info" icon to learn more about a given step.')
[docs] def initSetUp(self): super().initSetUp() if self.INSTRUCTION_TEXT: self._instruction_lbl = QtWidgets.QLabel(self.INSTRUCTION_TEXT) self._instruction_lbl.setObjectName("instruction_lbl") step_list = [] for cls in self.STEP_CLASSES: step = cls() step.connectToWorkflow(self) step_list.append(step) self._steps = step_list
[docs] def initLayOut(self): super().initLayOut() layout = self.main_layout layout.setContentsMargins(6, 6, 6, 6) if self.INSTRUCTION_TEXT: layout.addWidget(self._instruction_lbl) for step in self._steps: layout.addWidget(step) layout.addStretch()
[docs] def setModel(self, model): super().setModel(model) if model is not None: self.setEnabled(True) self.updateState()
[docs] def defineMappings(self): M = self.model_class mappings = [] for step in self._steps: if isinstance(step, SettingsStep): param = M.settings else: param = M mappings.append((step, param)) return mappings
[docs] def getSignalsAndSlots(self, model): return [ (model.target_template_pairsChanged, self.updateState), ]
[docs] def updateState(self): """ Updates the state of the workflow. """ self._disableUnreachableSteps() self._enableReachableSteps(self.model.target_template_pairs)
def _disableUnreachableSteps(self): """ Disable all but the first step and any SettingsStep """ for idx, step in enumerate(self._steps): enable = (idx == 0) or isinstance(step, SettingsStep) step.setEnabled(enable) def _enableReachableSteps(self, target_template_pairs): """ Enables the correct steps based on the target-template pairs. :param target_template_pairs: List of objects representing a (target sequence, template sequence) pair :type target_template_pairs: list[hm_models.TargetTemplatePair] """ n_pairs = len(target_template_pairs) if n_pairs == 0: return first_pair = target_template_pairs[0] target_seq = first_pair.target_seq template_seq = first_pair.template_seq self.template_step.setEnabled(target_seq is not None) if self.ligands_step is not None: # Ligand settings handle both ligands and waters, so enable if # either are present self.ligands_step.setEnabled(template_seq is not None and any(pair.ligands or pair.has_waters for pair in target_template_pairs)) has_one_template = target_seq is not None and template_seq is not None if self.SINGLE_TEMPLATE: align_enabled = has_one_template elif n_pairs > 1: second_pair = target_template_pairs[1] second_template = second_pair.template_seq align_enabled = has_one_template and second_template is not None else: align_enabled = False self.align_step.setEnabled(align_enabled)
[docs] def makeInitialModel(self): """ @overrides: MapperMixin We just use `None` as our initial model as a performance optimization. Workflows never need their own model in isolation anyways. """ return None
class _AbstractOneOneWorkflow(AbstractWorkflow): """ Base class for shared logic between OneOneWorkflow and HeteromultimerOneOneWorkflow """ SINGLE_TEMPLATE = True
[docs]class OneOneWorkflow(_AbstractOneOneWorkflow): STEP_CLASSES = [TargetSequenceStep, TemplateStructureStep, AlignSequencesStep, LigandsStep, SettingsStep] # yapf: disable optimizeAlignmentRequested = QtCore.pyqtSignal(str, str, float)
[docs] def initSetUp(self): super().initSetUp() self.align_step.optimizeAlignmentRequested.connect( self.optimizeAlignmentRequested)
[docs]class ManyOneWorkflow(AbstractWorkflow): STEP_CLASSES = [BatchTargetSequenceStep, BatchTemplateStructureStep, OtherTargetsStep, BatchAlignSequencesStep, LigandsStep, SettingsStep] # yapf: disable SINGLE_TEMPLATE = True setAsReferenceRequested = QtCore.pyqtSignal() importSeqRequested = QtCore.pyqtSignal()
[docs] def initSetUp(self): super().initSetUp() self.target_step = self._steps[0] self.other_targets_step = self._steps[2] self.other_targets_step.importSeqRequested.connect( self.importSeqRequested) self.template_step.setAsReferenceRequested.connect( self.setAsReferenceRequested)
def _enableReachableSteps(self, target_template_pairs): """ @overrides: AbstractWorkflow """ super()._enableReachableSteps(target_template_pairs) n_pairs = len(target_template_pairs) if n_pairs == 0: return first_pair = target_template_pairs[0] target_seq = first_pair.target_seq template_seq = first_pair.template_seq self.other_targets_step.setEnabled(target_seq is not None) self.align_step.setEnabled(template_seq is not None)
[docs]class ChimeraWorkflow(AbstractWorkflow): STEP_CLASSES = [TargetSequenceStep, MultipleTemplateStructuresStep, AlignStructuresSequencesStep, RegionsStep, MultipleTemplatesLigandStep, SettingsStep] # yapf: disable
[docs] def initSetUp(self): super().initSetUp() self.alignStructsRequested = self.align_step.alignStructsRequested self.regions_step = self._steps[3] self.chimera_pick_btn = self.regions_step.pick_btn
def _enableReachableSteps(self, target_template_pairs): """ @overrides: AbstractWorkflow """ super()._enableReachableSteps(target_template_pairs) align_enabled = self.align_step.isEnabled() self.regions_step.setEnabled(align_enabled)
[docs]class HomomultimerWorkflow(AbstractWorkflow): STEP_CLASSES = [TargetSequenceStep, HomomultimerTemplateStructuresStep, HomomultimerAlignSequencesStep, LigandsStep, SettingsStep] # yapf: disable
[docs]class ConsensusWorkflow(AbstractWorkflow): STEP_CLASSES = [TargetSequenceStep, MultipleTemplateStructuresStep, AlignStructuresSequencesStep] # yapf: disable
[docs] def initSetUp(self): super().initSetUp() self.alignStructsRequested = self.align_step.alignStructsRequested self.ligands_step = None
[docs]class HeteromultimerOneOneWorkflow(_AbstractOneOneWorkflow): STEP_CLASSES = [TargetSequenceStep, TemplateStructureStep, BaseAlignSequencesStep, LigandsStep] # yapf: disable INSTRUCTION_TEXT = None
[docs]class HeteromultimerChimeraWorkflow(ChimeraWorkflow): STEP_CLASSES = [TargetSequenceStep, MultipleTemplateStructuresStep, AlignStructuresSequencesStep, RegionsStep, MultipleTemplatesLigandStep] # yapf: disable INSTRUCTION_TEXT = None
[docs]class HeteromultimerWidget(_ToolTipMixin, mappers.MapperMixin, widgetmixins.InitMixin, QtWidgets.QWidget): ui_module = homology_multiple_view_tabs_ui model_class = gui_models.MsvGuiModel importSeqAsRefRequested = QtCore.pyqtSignal() findHomologsRequested = QtCore.pyqtSignal() importHomologsRequested = QtCore.pyqtSignal(bool) alignSeqsRequested = QtCore.pyqtSignal() alignStructsRequested = QtCore.pyqtSignal() copySeqsRequested = QtCore.pyqtSignal() removeLigandConstraintsRequested = QtCore.pyqtSignal() removeResidueConstraintsRequested = QtCore.pyqtSignal() ligandDialogClosed = QtCore.pyqtSignal() TOOLTIP = constants.HETERO_MULTIMER_WIDGET_TT TABLE_PAGE, STEPS_PAGE = range(2)
[docs] def initSetUp(self): super().initSetUp() self.setStyleSheet(heteromultimer_stylesheet) self.ui.distribute_chains_cb.setCheckable(False) self.ui.select_tabs_cb.setCheckable(False) self.tab_table = self._initTable() self.ui.top_tab_widget.tabBarClicked.connect(self._selectCurrentPage) self.ui.clear_all_btn.clicked.connect(self._clearAll) self.ui.select_all_btn.clicked.connect(self._selectAll) self.ui.copy_selected_btn.clicked.connect(self.copySeqsRequested) self.ui.copy_selected_btn.setIcon( QtGui.QIcon(':/msv/icons/Add-icon-green.png')) self.settings_step = SettingsStep() HeteroMode = constants.HeteromultimerMode self.mode_combo = EnumComboBox(enum=HeteroMode) self.mode_combo.updateItemTexts(constants.HETEROMULTIMER_MODE_TEXTS) for enum_item, tooltip in constants.HETEROMULTIMER_MODE_TOOLTIPS: index = self.mode_combo.index(enum_item) self.mode_combo.setItemData(index, tooltip, QtCore.Qt.ToolTipRole) self.steps_stacked_widget = mapperwidgets.EnumStackedWidget() for mode, WorkflowCls in [ (HeteroMode.ONE_ONE, HeteromultimerOneOneWorkflow), (HeteroMode.CHIMERA, HeteromultimerChimeraWorkflow), ]: widget = WorkflowCls() widget.importSeqAsRefRequested.connect(self.importSeqAsRefRequested) widget.findHomologsRequested.connect(self.findHomologsRequested) widget.importHomologsRequested.connect(self.importHomologsRequested) widget.alignSeqsRequested.connect(self.alignSeqsRequested) widget.removeLigandConstraintsRequested.connect( self.removeLigandConstraintsRequested) widget.removeResidueConstraintsRequested.connect( self.removeResidueConstraintsRequested) widget.ligandDialogClosed.connect(self.ligandDialogClosed) self.ligand_pick_btn = widget.ligand_pick_btn if mode is HeteroMode.CHIMERA: widget.alignStructsRequested.connect(self.alignStructsRequested) self.chimera_pick_btn = widget.chimera_pick_btn setattr(self, mode.name, widget) self.steps_stacked_widget.addWidget(widget) self.steps_stacked_widget.setEnum(HeteroMode)
[docs] def initLayOut(self): super().initLayOut() self.ui.tab_table_layout.addWidget(self.tab_table) self.ui.settings_layout.addWidget(self.settings_step) self.ui.mode_selector_layout.addWidget(self.mode_combo) self.ui.step_layout.addWidget(self.steps_stacked_widget)
[docs] def setModel(self, model): if model is not None: # Deselect any pages that may have been deleted while the homology # panel was closed new_selected = [ page for page in model.pages if page in model.heteromultimer_settings.selected_pages ] model.heteromultimer_settings.selected_pages.clear() model.heteromultimer_settings.selected_pages.extend(new_selected) super().setModel(model)
[docs] def defineMappings(self): M = self.model_class current_input = M.current_page.homology_modeling_input select_target = mappers.TargetSpec(slot=self._updateSelectButtons) mappings = [ (self.tab_table, M.pages), (self.tab_table.selection_target, M.heteromultimer_settings.selected_pages), (select_target, (M.pages, M.heteromultimer_settings.selected_pages)), (self.settings_step, M.heteromultimer_settings.settings), (self.mode_combo, current_input.heteromultimer_mode), (self.steps_stacked_widget, current_input.heteromultimer_mode), (self.ui.view_tab_name_lbl, M.current_page.title), # TODO elide text (EnabledTargetSpec(self.ui.copy_selected_btn), M.current_page.menu_statuses.can_duplicate_sequence), ] # yapf: disable for idx in range(self.steps_stacked_widget.count()): workflow = self.steps_stacked_widget.widget(idx) mappings.append((workflow, current_input)) return mappings
[docs] def getSignalsAndSlots(self, model): return [ (model.heteromultimer_settings.selected_pages.mutated, self.updateSelectedPages), ] # yapf: disable
[docs] def makeInitialModel(self): """ @overrides: MapperMixin We just use `None` as our initial model as a performance optimization. """ return None
def _initTable(self): table = view_tab_table.ViewTabTable() table.view.clicked.connect(self._onTableClicked) return table @property def mode(self): return self.mode_combo.currentItem() def _updateSelectButtons(self): all_pages = self.model.pages selected = self.model.heteromultimer_settings.selected_pages num_selected = len(selected) self.ui.clear_all_btn.setEnabled(bool(num_selected)) can_select_all = bool(all_pages) and num_selected < len(all_pages) self.ui.select_all_btn.setEnabled(can_select_all) def _clearAll(self): # Without single-shot timer, doesn't repaint QtCore.QTimer.singleShot( 0, self.model.heteromultimer_settings.selected_pages.clear) def _selectAll(self): def inner(): self.model.heteromultimer_settings.selected_pages = self.model.pages # Without single-shot timer, doesn't repaint QtCore.QTimer.singleShot(0, inner) def _onTableClicked(self, index): """ If the user clicks on the settings column, switch to the associated tab and the steps page """ col = index.column() if col != self.tab_table.settings_column_index: return page = index.data(plptable.ROW_OBJECT_ROLE) self._activatePage(page) self.model.current_page = page self.ui.top_tab_widget.setCurrentIndex(self.STEPS_PAGE) def _selectCurrentPage(self, index): """ If the user clicks on the steps page, select the current widget for heteromultimer """ if index == self.STEPS_PAGE: selected_pages = self.model.heteromultimer_settings.selected_pages if self.model.current_page not in selected_pages: selected_pages.append(self.model.current_page)
[docs] def updateSelectedPages(self): for page in self.model.heteromultimer_settings.selected_pages: self._activatePage(page)
def _activatePage(self, page): # Homology modeling doesn't work with combined chain mode so # automatically switch to split chain mode page.split_chain_view = True page.homology_modeling_input.mode = Mode.HETEROMULTIMER page.homology_modeling_input._setAlignment(page.split_aln)
MODE_WORKFLOWS = { Mode.ONE_ONE: OneOneWorkflow, Mode.MANY_ONE: ManyOneWorkflow, Mode.CHIMERA: ChimeraWorkflow, Mode.HOMOMULTIMER: HomomultimerWorkflow, Mode.CONSENSUS: ConsensusWorkflow, Mode.HETEROMULTIMER: HeteromultimerWidget }