Source code for schrodinger.application.msv.gui.msv_widget
import copy
import cProfile as profile
import itertools
import os
import time
import weakref
from collections import Counter
from collections import defaultdict
from functools import partial
import decorator
import inflect
import schrodinger
from schrodinger.application.msv import command
from schrodinger.application.msv import seqio
from schrodinger.application.msv import structure_model
from schrodinger.application.msv.gui import color
from schrodinger.application.msv.gui import dialogs
from schrodinger.application.msv.gui import gui_alignment
from schrodinger.application.msv.gui import gui_models
from schrodinger.application.msv.gui import picking
from schrodinger.application.msv.gui import toolbar
from schrodinger.application.msv.gui import validate_align
from schrodinger.application.msv.gui import view
from schrodinger.application.msv.gui import viewconstants
from schrodinger.application.msv.gui import viewmodel
from schrodinger.application.msv.gui.picking import PickMode
from schrodinger.infra import util
from schrodinger.models import mappers
from schrodinger.protein import align
from schrodinger.protein import annotation
from schrodinger.protein import constants
from schrodinger.protein import predictors
from schrodinger.protein import properties
from schrodinger.protein import residue
from schrodinger.protein import sequence
from schrodinger.protein.tasks import binding_site_align
from schrodinger.protein.tasks import blast
from schrodinger.protein.tasks import clustal
from schrodinger.protein.tasks import descriptors
from schrodinger.protein.tasks import kinase
from schrodinger.protein.tasks import muscle
from schrodinger.protein.tasks import optimize_alignment
from schrodinger.protein.tasks import pfam
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtPrintSupport
from schrodinger.Qt import QtSvg
from schrodinger.Qt import QtWidgets
from schrodinger.structutils.interactions import protein_protein_interactions
from schrodinger.tasks import queue
from schrodinger.tasks import tasks
from schrodinger.ui.qt import appframework as af1
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt.appframework2 import application
from schrodinger.utils import profiling
from typing import Union
maestro = schrodinger.get_maestro()
SEQ_ANNO_TYPES = annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES
PRED_ANNO_TYPES = annotation.ProteinSequenceAnnotations.PRED_ANNOTATION_TYPES
ALN_ANNO_TYPES = annotation.ProteinAlignmentAnnotations.ANNOTATION_TYPES
RES_PROP_ANNOS = annotation.ProteinSequenceAnnotations.RES_PROPENSITY_ANNOTATIONS
PRED_ANNO_TO_PRED_FUNC = predictors.PRED_ANNO_TO_PRED_FUNC
PROPNAME_BINDINGSITE_RMSD = 'r_psp_BindingSiteAlign_RMSD'
MAX_FASTA_LINE_LENGTH = 60
SENTINEL = object()
_DESCRIPTORS_CLEARED_WARNING_MSG = (
    "The descriptor values associated with the modified sequences are invalid and "
    "will be discarded. \n\nWhen you have finished editing the sequences or "
    "structures, use Compute Sequence Descriptors... from the "
    "Other Tasks menu to recompute them.")
[docs]def format_seq_name_with_chain(seq, include_chain):
    """
    Return a name identifying the sequence, with the chain name optionally in
    parentheses
    :param seq: The sequence to format
    :type seq: `sequence.Sequence`
    :param include_chain: Whether to include the chain in the name
    :type include_chain: bool
    :return: A formatted name to identify the sequence
    :rtype: str
    """
    name = "" if seq is None else seq.name
    if name and include_chain:
        chain_name = seq.chain
        if chain_name:
            name += f" ({chain_name})"
    return name
def _getNextSetName(last_name):
    """
    Generate a name string for the set by appending a number incrementally to
    the previous name. eg ::
        'Set 1' --> 'Set 2'
        'MySet' --> 'MySet 2'
    :param last_name: Mame of the previous set.
    :type last_name: str
    :rtype: str
    """
    split_list = last_name.split()
    num = split_list[-1]
    if num.isnumeric():
        new_num = int(num) + 1
        split_list[-1] = str(new_num)
    else:
        split_list.append("2")
    name = " ".join(split_list)
    return name
[docs]class AbstractMsvWidget(mappers.MapperMixin, basewidgets.BaseWidget):
    """
    Acts as a controller/container for a ProteinAlignment, the viewmodel proxy
    stacks that wrap it, and the views required to present these proxy stacks.
    Each tab in the MSV contains a different instance of an MSV Widget.
    :cvar residueHovered: Signal emitted when a residue cell is hovered
    :cvar residueUnhovered: Signal emitted when a residue cell is unhovered
    :cvar resHighlightStatusChanged: Signal emitted when residue highlighting
        changes. Emitted with whether any residues are highlighted.
    :cvar renameSequenceRequested: A signal emitted to notify listeners to
        open the rename sequence dialog
    :ivar taskStarted: Signal emitted when a task is started. Task types should
        have a description defined in `toolbar.TaskStatusBar.PRETTY_TASK_NAMES`.
    """
    model_class = gui_models.PageModel
    duplicateIntoNewTabRequested = QtCore.pyqtSignal()
    translateIntoNewTabRequested = QtCore.pyqtSignal(list)
    residueHovered = QtCore.pyqtSignal(object)
    residueUnhovered = QtCore.pyqtSignal()
    openColorPanelRequested = QtCore.pyqtSignal()
    resHighlightStatusChanged = QtCore.pyqtSignal(bool)
    editToolbarUpdateRequested = QtCore.pyqtSignal()
    renameSequenceRequested = QtCore.pyqtSignal()
    taskStarted = QtCore.pyqtSignal(tasks.AbstractTask)
    # We want to flag when the MSV widget modifies the options model so that
    # changes in the options model don't re-trigger changes in the MSV widget.
    _changingOptionsModel = util.flag_context_manager("_changing_options_model")
[docs]    def __init__(self, parent=None, struc_model=None, *, undo_stack):
        """
        :param parent: The Qt parent
        :type parent: QtWidgets.QWidget
        :param struc_model: The structure model to use when interacting with
            sequences associated with three-dimensional structures.  If None, a
            new structure model will be created.
        :type struc_model: structure_model.StructureModel
        :param undo_stack: The undo stack to use.
        :type undo_stack: schrodinger.application.msv.command.UndoStack
        """
        self._curr_color_scheme = None
        # TODO MSV-2239 find better way to hide initialization commands
        # We set the undo stack below.  If we set it here then some
        # initialization steps would unintentionally add commands to the stack.
        self.undo_stack = None
        self._undo_stack = undo_stack  # Needed to create alignment view
        super().__init__(parent=parent)
        self.setStructureModel(struc_model)
        # Now that initialization is done, we can safely set the undo stack
        # without the risk of unintentionally adding commands.
        self.undo_stack = undo_stack
        del self._undo_stack
        self.getAlignment().setUndoStack(self.undo_stack)
[docs]    def initSetOptions(self):
        super().initSetOptions()
        self._changing_options_model = None
        self._current_pattern_idx = None
        self._table_model = None
        self.dendrogram_viewer = None
        self._syncing_dendrogram_selection = False
        self.get_sequences_dialog = None
        self.noscroll_view = None
        self._light_mode = False
        self._previous_set_name = None
[docs]    def initSetUp(self):
        super().initSetUp()
        self.seq_status_model = toolbar.AlnStatusModel()
        self.res_status_model = toolbar.ResStatusModel()
        self._table_model = self._getAlignmentViewModel()
        # viewer related setup
        self.view = self._getAlignmentView(self._undo_stack)
        self.view.setFrameShape(QtWidgets.QFrame.NoFrame)
        self.view.residueHovered.connect(self.residueHovered)
        self.view.residueUnhovered.connect(self.residueUnhovered)
        self.view.residueMiddleClicked.connect(self._onResidueMiddleClicked)
        self.view.openColorPanelRequested.connect(self.openColorPanelRequested)
        self.aln_info_view = self._getAlignmentInfoView()
        # yapf: disable
        self.aln_info_view.deleteFromAllRequested.connect(self.deleteAnnsFromAll)
        self.aln_info_view.clearAnnotationRequested.connect(self.clearAnnotations)
        self.aln_info_view.clearConstraintsRequested.connect(self.clearConstraints)
        self.aln_info_view.deselectResiduesClicked.connect(self.deselectResForSeqs)
        self.aln_info_view.duplicateAsRefSeqRequested.connect(self.duplicateAsRef)
        self.aln_info_view.duplicateAtBottomRequested.connect(self.duplicateAtBottom)
        self.aln_info_view.duplicateAtTopRequested.connect(self.duplicateAtTop)
        self.aln_info_view.duplicateInPlaceRequested.connect(self.duplicateInPlace)
        self.aln_info_view.duplicateIntoNewTabRequested.connect(self.duplicateIntoNewTabRequested)
        self.aln_info_view.findHomologsClicked.connect(self.blastSelectedSeq)
        self.aln_info_view.findInListRequested.connect(self.enableFindSequence)
        self.aln_info_view.mvSeqsClicked.connect(self.moveSelectedSequences)
        self.aln_info_view.renameSeqClicked.connect(self.renameSequenceRequested)
        self.aln_info_view.renameAlnSetClicked.connect(self._renameAlnSet)
        self.aln_info_view.selectAlnSetClicked.connect(self._selectAlnSet)
        self.aln_info_view.deselectAlnSetClicked.connect(self._deselectAlnSet)
        self.aln_info_view.dissolveAlnSetClicked.connect(self._dissolveAlnSet)
        self.aln_info_view.gatherAlnSetsClicked.connect(self._gatherAlnSets)
        self.aln_info_view.rmSeqsClicked.connect(self.removeSelectedSeqs)
        self.aln_info_view.exportSequencesRequested.connect(self._exportSelectedSeqs)
        self.aln_info_view.hideSeqsRequested.connect(self.hideSelectedSeqs)
        self.aln_info_view.selectRowResidues.connect(self.selectResForSeqs)
        self.aln_info_view.setAsReferenceSeq.connect(self.setSelectedSeqAsReference)
        self.aln_info_view.sortRequested.connect(self.sortBy)
        self.aln_info_view.alnSetCreateRequested.connect(self._createAlnSet)
        self.aln_info_view.alnSetAddRequested.connect(self._addToAlnSet)
        self.aln_info_view.alnSetRemoveRequested.connect(self._removeSeqsFromAlnSet)
        self.aln_info_view.translateDnaRnaRequested.connect(
            self.translateSelectedSequences)
        # yapf: enable
        self.aln_metrics_view = self._getAlignmentMetricsView()
        self.aln_metrics_view.sortRequested.connect(self.sortBy)
        # Main view widget and load sequences bar setup
        self.view_widget = QtWidgets.QWidget(self)
        self.view_widget.setObjectName("view_widget")
        self._table_model.topModelChanged.connect(self.view.setModel)
        self.aln_metrics_view.setModel(self._table_model.metrics_model)
        self.aln_info_view.setModel(self._table_model.info_model)
        self._seq_export_dialog = None
        self._image_export_dialog = None
[docs]    def initLayOut(self):
        super().initLayOut()
        view_layout = QtWidgets.QGridLayout()
        self.view_widget.setLayout(view_layout)
        view_layout.setSpacing(0)
        # Extract scrollbar from the view and insert it to the left of the view
        self.v_scrollbar = self.view.verticalScrollBar()
        self.v_scrollbar.setObjectName("v_scrollbar")
        self.v_scrollbar.setEnabled(False)
        self.v_scrollbar.rangeChanged.connect(self.onVScrollBarRangeChanged)
        view_layout.addWidget(self.v_scrollbar, 0, 0)
        # MSV-1875 Place horizontal scrollbar beneath views to prevent
        # blocking of viewports
        self.view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.h_scrollbar = self.view.horizontalScrollBar()
        self.h_scrollbar.setObjectName("h_scrollbar")
        view_layout.addWidget(self.h_scrollbar, 1, 1)
        view_layout.addWidget(self.view, 0, 1)
        view_layout.addWidget(self.aln_metrics_view, 0, 2)
        view_layout.setContentsMargins(0, 0, 0, 0)
        view_layout.setRowStretch(0, 1)
        # Align views on 2 sides of the splitter by adding padding to aln_info:
        hbarheight = self.h_scrollbar.height()
        aln_info_layout = QtWidgets.QHBoxLayout()
        aln_info_layout.setContentsMargins(0, 0, 0, hbarheight)
        aln_info_layout.addWidget(self.aln_info_view)
        # Create a splitter, with aln_info_view on the left, and view_widget
        # on the right:
        self.splitter = QtWidgets.QSplitter()
        self.splitter.setObjectName("msv_widget_splitter")
        outer_layout = QtWidgets.QHBoxLayout(self.splitter)
        outer_layout.addLayout(aln_info_layout)
        outer_layout.addWidget(self.view_widget)
        outer_layout.setContentsMargins(0, 0, 0, 0)
        # So the splitter doesn't snap shut
        self.splitter.setCollapsible(0, False)
        self.splitter.setCollapsible(1, False)
        # Resizing the main window should resize view_widget not aln_info
        self.splitter.setStretchFactor(1, 1)
        # Setting a minimum width so at least one column appears
        self.view.setMinimumWidth(25)
        self.layout().setContentsMargins(0, 0, 0, 0)
        self.layout().addWidget(self.splitter)
        self.updateColorsEnabled()
        self.updateSeqColorScheme()
        # don't enable row wrap until after the table's geometry is set
        QtCore.QTimer.singleShot(0, self.updateRowWrap)
[docs]    @QtCore.pyqtSlot(int, int)
    def onVScrollBarRangeChanged(self, _, max_):
        """
        When the vertical scrollbar is disabled, (i.e. the min and max are 0)
        style it to be mostly invisible.
        """
        self.v_scrollbar.setEnabled(max_ != 0)
        qt_utils.update_widget_style(self.v_scrollbar)
[docs]    def setModel(self, model):
        # MapperMixin
        super().setModel(model)
        self.setProperty("is_ws_tab", self.model.is_workspace)
        self.view.setIsWorkspace(self.model.is_workspace)
        self.view.setMenuModel(self.model.menu_statuses)
        self.aln_info_view.setMenuModel(self.model.menu_statuses)
        self._table_model.setPageModel(model)
        self._onAlnChanged(model.aln)
        self.updateExpandEnabled()
        self.view.setModel(self._table_model.top_model)
        self._loadInitialColorScheme()
        self._updateRenumberResEnabled()
        self.updateGetStructureLinkSeq()
    def _loadInitialColorScheme(self):
        """
        Load the color scheme from the model that was just set.
        """
        if self.model.options.colors_enabled:
            scheme = self.model.options.seq_color_scheme
        else:
            scheme = color.NoColorScheme()
        self._table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                            scheme)
        if isinstance(scheme, color.WorkspaceScheme):
            # If we're in the process of loading the Maestro project, then the
            # workspace isn't populated yet, so we need to wait before we get
            # the workspace colors
            QtCore.QTimer.singleShot(0, self.pullWorkspaceColors)
[docs]    def getSignalsAndSlots(self, model):
        options_model = model.options
        signals = model.aln_signals
        ss = [
            (signals.hiddenSeqsChanged, self._updateSeqStatusModel),
            (signals.resHighlightStatusChanged, self.resHighlightStatusChanged),
            (signals.resHighlightStatusChanged, self.updateRemoveHighlightsEnabled),
            (signals.resOutlineStatusChanged, self._updateResOutlineState),
            (signals.pairwiseConstraintsChanged, self._updateClearConstraintsEnabled),
            (signals.syncWsResSelection, self._syncWsResSelectionForSeqs),
            (signals.sequenceStructureChanged, self.updateCanUnlinkSequences),
            (signals.sequenceStructureChanged, self.updateGetStructureLinkSeq),
            (signals.resSelectionChanged, self._onResSelectionChanged),
            (signals.seqSelectionChanged, self._onSeqSelectionChanged),
            (signals.alnSetChanged, self._onAlnSetChanged),
            (model.alnChanged, self._onAlnChanged),
            (options_model.colors_enabledChanged, self.updateColorsEnabled),
            (options_model.group_byChanged, self.updateExpandEnabled),
            (options_model.seq_color_schemeChanged, self.updateSeqColorScheme),
            (options_model.wrap_sequencesChanged, self.updateRowWrap),
            (options_model.pick_modeChanged, self._updatePickMode),
            (options_model.custom_color_schemeChanged, self._onCustomColorSchemeChanged),
            (signals.predictionsChanged, self._updateCanDeleteAllPredictions),
            (signals.alignmentNumColumnsChanged, self._updateColorScheme),
            (signals.sequencesReordered, self._updateColorScheme),
            (signals.anchoredResiduesChanged, self.updateAnchorSelectionEnabled),
            (signals.sequenceVisibilityChanged, self._updateStatusModelNumStructs),
            (signals.sequencesAboutToBeInserted, self._clearSeqFilter),
            (signals.sequencesInserted, self._updateSeqStatusModel),
            (signals.sequencesInserted, self.updateSelectMenuEnabled),
            (signals.sequencesInserted, self._updateRenumberResEnabled),
            (signals.sequencesInserted, self.updateSelectProteinInterfaceMenuEnabled),
            (signals.sequencesInserted, self._generateKinaseFeatures),
            (signals.sequencesRemoved, self._updateSeqStatusModel),
            (signals.sequencesRemoved, self.updateSelectMenuEnabled),
            (signals.sequencesRemoved, self.updateSelectProteinInterfaceMenuEnabled),
            (signals.sequencesRemoved, self._updateRenumberResEnabled),
            (signals.sequencesReordered, self._updateStatusModelRefSeq),
            (signals.sequencesReordered, self.updateMoveSequencesEnabled),
            (signals.sequencesReordered, self.updateSetAsRefEnabled),
            (signals.sequenceStructureChanged, self.updateSelectProteinInterfaceMenuEnabled),
            (signals.homologyCompositeResiduesChanged, self._onCompositeResiduesChanged),
        ]  # yapf: disable
        edit_toolbar_signals = [
            signals.alignmentCleared,
            signals.sequencesInserted,
            signals.sequencesRemoved,
            signals.sequencesReordered,
            signals.residuesRemoved,
            signals.residuesAdded,
            signals.resSelectionChanged
        ]  # yapf: disable
        for cur_sig in edit_toolbar_signals:
            ss.append((cur_sig, self._updateResStatusAndEditStatus))
        return ss
    @QtCore.pyqtSlot()
    def _onResSelectionChanged(self):
        self.updateAnchorSelectionEnabled()
        self.updateRemoveHighlightsEnabled()
        self._updateCanAlignBindingSites()
    @QtCore.pyqtSlot()
    def _onSeqSelectionChanged(self):
        self.updateCanUnlinkSequences()
        self._updateStatusModelNumSelected()
        self.updateAlignSelectedSeqsEnabled()
        self.updateDeleteSequencesEnabled()
        self.updateDuplicateSeqOptionsEnabled()
        self._updateEditSequenceAsTextEnabled()
        self.updateMoveSequencesEnabled()
        self.updateRenameSeqEnabled()
        self.updateSetAsRefEnabled()
        self._updateRemoveFromAlnSetEnabled()
        self._updateAlignAlnSetEnabled()
        self._updateDendrogramSelection()
        self.updateGetStructureLinkSeq()
[docs]    def getValidIdMap(self):
        """
        Get the valid id map for the currently selected sequences.
        :return: Map of valid PDB IDs to their source sequence
        :rtype: dict(str: sequence.Sequence)
        """
        sel_seqs = self.getSelectedSequences()
        valid_id_map = seqio.get_valid_pdb_id_map_for_seqs(sel_seqs)
        return valid_id_map
[docs]    def updateGetStructureLinkSeq(self):
        """
        Update if getting the structure from PDB action is enabled in the view.
        """
        valid_id_map = self.getValidIdMap()
        seq_menu_model = self.aln_info_view.seq_context_menu.model
        if self.isWorkspace():
            seq_menu_model.can_get_pdb_sts = False
            seq_menu_model.can_link_or_unlink_sequences = False
        else:
            seq_menu_model.can_get_pdb_sts = bool(valid_id_map)
            seq_menu_model.can_link_or_unlink_sequences = True
    def _updateDendrogramSelection(self):
        """
        Update selection in the dendrogram so that it matches selection in the
        MSV
        """
        if not self.dendrogram_viewer:
            return
        selected_seqs = self.getSelectedSequences()
        with qt_utils.suppress_signals(self.dendrogram_viewer):
            self.dendrogram_viewer.setSelection(selected_seqs)
    @util.skip_if("_syncing_dendrogram_selection")
    def _updateSelectionFromDendrogram(self):
        """
        Respond to selection changes from the dendrogram by selecting
        corresponding sequences
        """
        self._syncing_dendrogram_selection = True
        seqs_to_select = self.dendrogram_viewer.getSelection()
        self.setSelectedSequences(seqs_to_select)
        self._syncing_dendrogram_selection = False
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(color.AbstractRowColorScheme)
    def _onCustomColorSchemeChanged(self, scheme):
        """
        Update colors if the custom color scheme changed. This is for handling
        when the custom color scheme is changed on a different page and this
        tab is currently using the custom color scheme.
        """
        model = self.model
        table_model = self._table_model
        if not self._changing_options_model and model.options.seq_color_scheme.custom:
            if scheme is not None:
                model.options.seq_color_scheme = scheme
                table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                              scheme)
            else:
                model.reset(gui_models.PageModel.options.seq_color_scheme)
                table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                              model.options.seq_color_scheme)
    @property
    def options_model(self):
        return self.model.options
    @property
    def image_export_dialog(self):
        """
        Delay instantiation of the image export dialog until it is needed to
        prevent unnecessary file handles from being opened
        :return: the image export dialog
        :rtype: dialogs.MSVImageExportDialog
        """
        if self._image_export_dialog is None:
            self._image_export_dialog = dialogs.MSVImageExportDialog(self)
        return self._image_export_dialog
    @property
    def seq_export_dialog(self):
        """
        Delay instantiation of the image export dialog until it is needed to
        prevent unnecessary file handles from being opened
        :return: the image export dialog
        :rtype: dialogs.MSVSequenceExportDialog
        """
        if self._seq_export_dialog is None:
            self._seq_export_dialog = dialogs.MSVSequenceExportDialog(self)
        return self._seq_export_dialog
[docs]    def openPropertyDialog(self):
        """
        Show the dialog that controls which properties are shown in the
        alignment metrics view
        """
        dlg = dialogs.ManagePropertiesDialog(
            self.model.aln, self.model.options.sequence_properties, self)
        dlg.seq_props_updated.connect(self._onSeqPropsUpdated)
        dlg.adjustSize()
        dlg.run(modal=True)
    @QtCore.pyqtSlot(list)
    def _onSeqPropsUpdated(self, seq_props):
        self.model.options.sequence_properties = seq_props
        # TODO: LOOK AT ME
        self.model.options.sequence_propertiesChanged.emit(
            self.model.options.sequence_properties)
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(gui_alignment.GuiProteinAlignment)
    def _onAlnChanged(self, aln):
        self._updateSeqStatusModel()
        self._updateResStatusAndEditStatus()
        self.updateRemoveHighlightsEnabled()
        self._onAlnSetChanged()
        if self.undo_stack is not None:  # TODO remove check after MSV-2239
            aln.setUndoStack(self.undo_stack)
        self._table_model.setAlignment(aln)
[docs]    def setStructureModel(self, model=None):
        if model is None:
            model = structure_model.StructureModel(self, self.undo_stack)
        self._structure_model = model
        self._structure_model.workspaceColorsChanged.connect(
            self.onWorkspaceColorsChanged)
        self._table_model.setStructureModel(model)
[docs]    def getShownSplitSequences(self):
        """
        Return the currently shown single-chain sequences in the order they
        appear in the alignment
        :rtype: tuple
        """
        aln = self.model.split_aln
        shown_states = aln.getSeqShownStates()
        return tuple(s for s, shown in zip(aln, shown_states) if shown)
[docs]    def getSelectedSequences(self):
        """
        Return a list of the currently selected sequences in the order they
        appear in the alignment
        :return: list of selected sequences
        :rtype: list of `sequence.Sequence`
        """
        aln = self.getAlignment()
        return aln.getSelectedSequences()
[docs]    def setSelectedSequences(self, seqs):
        """
        Set sequence selection to the specified sequences.
        :param seqs: List of sequences to select
        :type seqs: list of
            `schrodinger.protein.sequence.Sequence`
        """
        aln = self.getAlignment()
        seq_selection_model = aln.seq_selection_model
        seq_selection_model.clearSelection()
        if seqs:
            seq_selection_model.setSelectionState(seqs, True)
[docs]    def selectAllSequences(self):
        """
        Select all sequences
        """
        self.setSelectedSequences(self.getAlignment())
[docs]    def selectSequencesWithStructure(self):
        """
        Select all sequences with structures.
        """
        aln = self.getAlignment()
        seqs = [s for s in aln if s.hasStructure()]
        self.setSelectedSequences(seqs)
[docs]    def selectSequenceByIdentity(self):
        """
        Open a dialog to select sequences by identity.
        """
        dialogs.SelectByIdentityDialog(parent=self).run()
[docs]    def invertSequenceSelection(self):
        """
        Invert sequence selection.
        """
        aln = self.getAlignment()
        sel_seqs = set(aln.getSelectedSequences())
        seqs = [s for s in aln if s not in sel_seqs]
        self.setSelectedSequences(seqs)
[docs]    def selectAntibodyHeavyChain(self):
        aln = self.getAlignment()
        heavy_seqs = [s for s in aln if s.annotations.isAntibodyHeavyChain()]
        self.setSelectedSequences(heavy_seqs)
[docs]    def selectAntibodyLightChain(self):
        aln = self.getAlignment()
        light_seqs = [s for s in aln if s.annotations.isAntibodyLightChain()]
        self.setSelectedSequences(light_seqs)
[docs]    def selectAllResidues(self):
        """
        Selects all residues
        """
        aln = self.getAlignment()
        aln.res_selection_model.selectAll()
[docs]    def deselectAllResidues(self):
        """
        Deselects all residues
        """
        aln = self.getAlignment()
        aln.res_selection_model.clearSelection()
[docs]    def selectResiduesWithStructure(self):
        """
        Selects all residues with structure
        """
        self.getAlignment().selectResiduesWithStructure()
[docs]    def selectAntibodyCDR(self):
        """
        Select residues with Antibody CDR.
        """
        self.getAlignment().selectAntibodyCDR(
            scheme=self.options_model.antibody_cdr_scheme)
[docs]    def selectColsWithStructure(self):
        """
        Select all columns that contain only structured residues.
        """
        self.getAlignment().selectColsWithStructure()
[docs]    def selectBindingSites(self):
        """
        Select residues with binding site contacts.
        """
        self.getAlignment().selectBindingSites()
[docs]    def selectProteinInterface(self):
        """
        Select the protein interface.
        """
        aln = self.getAlignment()
        structs = list(aln.all_structures)
        interface_atoms = protein_protein_interactions.get_interface_atoms(
            structs)
        interface_residues = {
            (at.getResidue(), at.entry_id) for at in interface_atoms
        }
        struct_res_keys = {
            residue.get_structure_residue_key(*info)
            for info in interface_residues
        }
        def should_select(res):
            seq = res.sequence
            res_key = residue.get_residue_key(res, seq.entry_id, seq.chain)
            return res_key in struct_res_keys
        residues_to_select = [
            res for res in aln.getResiduesWithStructure() if should_select(res)
        ]
        aln.res_selection_model.setSelectionState(residues_to_select, True)
[docs]    def invertResSelection(self):
        """
        Invert the selection
        """
        self.getAlignment().invertResSelection()
[docs]    @prompt_if_seqs_hidden
    def deleteRedundantSeqs(self):
        """
        Show the dialog for defining and removing redundant sequences
        """
        self._del_redundant_dlg = dialogs.DeleteRedundantSeqsDialog(self)
        self._del_redundant_dlg.exec_()
        self._del_redundant_dlg = None
[docs]    def deleteAllPredictions(self):
        aln = self.getAlignment()
        for seq in aln:
            seq.deleteAllPredictions()
[docs]    @QtCore.pyqtSlot()
    def deleteAnnsFromAll(self):
        """
        Delete selected annotations from all sequences.
        """
        opts = self.options_model
        ann_sel_model = self.model.aln.ann_selection_model
        pfam_names = set()
        pred_types = set()
        for ann_info in ann_sel_model.getSelection():
            ann = ann_info.ann
            if ann is SEQ_ANNO_TYPES.pfam:
                pfam_name = ann_info.seq.pfam_name
                if pfam_name is not None:
                    pfam_names.add(pfam_name)
            elif ann in PRED_ANNO_TYPES:
                pred_types.add(ann)
        if pfam_names:
            any_pfam = False
            for seq in self.getAlignment():
                if seq.pfam_name in pfam_names:
                    seq.clearPfam()
                elif any_pfam is False and seq.hasPfam():
                    any_pfam = True
            if not any_pfam:
                pred_types.add(SEQ_ANNO_TYPES.pfam)
        # Hide specified prediction types
        opts.predicted_annotations = opts.predicted_annotations - pred_types
[docs]    @QtCore.pyqtSlot()
    def clearAnnotations(self):
        """
        Hide all selected annotations. Does not affect predicted annotations.
        """
        opts = self.options_model
        ann_sel_model = self.model.aln.ann_selection_model
        anns = {
            ann_info.ann
            for ann_info in ann_sel_model.getSelection()
            if ann_info.ann not in PRED_ANNO_TYPES
        }
        for included_annotations in [
                opts.sequence_annotations, opts.alignment_annotations,
                opts.residue_propensity_annotations
        ]:
            if included_annotations & anns:
                # Explicitly checking intersection first because params in-place
                # operations skip equality checks for performance reasons and
                # always emit changed signals
                included_annotations.difference_update(anns)
    @QtCore.pyqtSlot(object)
    def _updatePickMode(self, pick_mode):
        """
        When the pick mode is changed, modify the alignment as needed
        """
        aln = self.getAlignment()
        show_constraints = pick_mode is PickMode.HMProximity
        if pick_mode is PickMode.HMChimera:
            self.model.options.color_by_aln = viewconstants.ColorByAln.Matching
            # Turning off chimera is handled by homology panel
            self.view.setChimeraShown(True)
        elif pick_mode is PickMode.Pairwise:
            if len(aln) > 1:
                ref_seq = aln.getReferenceSeq()
                self.moveSelectedSequences(viewconstants.Direction.Top)
                self.setSequenceExpansionState([ref_seq])
                show_constraints = True
        self.view.setConstraintsShown(show_constraints)
        self.view.setLigandConstraintsShown(pick_mode is PickMode.HMBindingSite)
[docs]    def resetPick(self, pick_mode):
        """
        Set the default pick for the specified pick mode
        """
        if pick_mode is None:
            return
        aln = self.getAlignment()
        picking.handle_reset_pick(aln, pick_mode)
[docs]    @QtCore.pyqtSlot()
    def clearConstraints(self):
        self.getAlignment().resetPairwiseConstraints()
    @QtCore.pyqtSlot()
    def _onCompositeResiduesChanged(self):
        """
        If composite residues becomes empty, stop picking
        """
        if not self.getAlignment().homology_composite_residues:
            self.model.options.pick_mode = None
    # ==========================================================================
    # Menu enabling
    # ==========================================================================
[docs]    @QtCore.pyqtSlot()
    def updateCanUnlinkSequences(self):
        if self.isWorkspace():
            can_unlink_sequences = False
        else:
            aln = self.getAlignment()
            selection = aln.seq_selection_model.getSelection()
            can_unlink_sequences = any(seq.hasStructure() for seq in selection)
        self.model.menu_statuses.can_unlink_sequences = can_unlink_sequences
[docs]    @QtCore.pyqtSlot()
    def updateDeleteSequencesEnabled(self):
        if self.isWorkspace():
            can_delete_seqs = False
        else:
            aln = self.getAlignment()
            can_delete_seqs = aln.seq_selection_model.hasSelection()
        self.model.menu_statuses.can_delete_sequences = can_delete_seqs
[docs]    @QtCore.pyqtSlot()
    def updateMoveSequencesEnabled(self):
        aln = self.getAlignment()
        ref_seq = aln.getReferenceSeq()
        sel_seqs = aln.seq_selection_model.getSelection()
        can_move_seqs = bool(sel_seqs) and not any(seq == ref_seq
                                                   for seq in sel_seqs)
        self.model.menu_statuses.can_move_sequence = can_move_seqs
[docs]    @QtCore.pyqtSlot()
    def updateSetAsRefEnabled(self):
        """
        Enable/disable the menu model for Set As Reference Sequence
        """
        aln = self.getAlignment()
        seqs = aln.getSelectedSequences()
        one_seq_selected = len(seqs) == 1
        ref_seq = aln.getReferenceSeq()
        can_set_ref = one_seq_selected and seqs[0] != ref_seq
        self.model.menu_statuses.can_set_as_ref = can_set_ref
        self.model.menu_statuses.can_duplicate_as_ref = can_set_ref and not self.isWorkspace(
        )
[docs]    @QtCore.pyqtSlot()
    def updateRenameSeqEnabled(self):
        aln = self.getAlignment()
        sel_seqs = aln.getSelectedSequences()
        self.model.menu_statuses.can_rename_seq = (len(sel_seqs) == 1)
[docs]    def updateDuplicateSeqOptionsEnabled(self):
        aln = self.getAlignment()
        can_duplicate_sequence = aln.seq_selection_model.hasSelection()
        menu_statuses = self.model.menu_statuses
        menu_statuses.can_duplicate_sequence = can_duplicate_sequence
        menu_statuses.can_duplicate_seq_same_tab = (not self.isWorkspace() and
                                                    can_duplicate_sequence)
    @QtCore.pyqtSlot()
    def _updateRenumberResEnabled(self):
        """
        Enables the 'Renumber Residues...' menu-item only when there is a
        sequnce in the MSV workspace.
        """
        can_renumber_residues = bool(self.getAlignment())
        self.model.menu_statuses.can_renumber_residues = can_renumber_residues
    @QtCore.pyqtSlot()
    def _updateEditSequenceAsTextEnabled(self):
        """
        Enables the 'Edit Sequence as Plain Text...' menu-item only when there
        is exactly one sequence selected in the MSV View and it is unlinked and
        not in an aln set.
        """
        aln = self.getAlignment()
        sel_seqs = aln.seq_selection_model.getSelection()
        if self.isWorkspace() or len(sel_seqs) != 1:
            can_edit_sequence = False
        else:
            sel_seq = sel_seqs.pop()
            can_edit_sequence = (not sel_seq.hasStructure() and
                                 aln.alnSetForSeq(sel_seq) is None)
        self.model.menu_statuses.can_edit_as_text = can_edit_sequence
[docs]    @QtCore.pyqtSlot()
    def updateSelectMenuEnabled(self):
        can_select = bool(self.getAlignment())
        self.model.menu_statuses.can_select = can_select
[docs]    @QtCore.pyqtSlot()
    def updateSelectProteinInterfaceMenuEnabled(self):
        aln = self.getAlignment()
        can_select = bool(aln)
        if can_select and not self.isWorkspace():
            can_select = any(seq.hasStructure() for seq in aln)
        self.model.menu_statuses.can_select_protein_interface = can_select
[docs]    @QtCore.pyqtSlot()
    def updateExpandEnabled(self):
        can_expand = (self.options_model.group_by
                      is not viewconstants.GroupBy.Type)
        self.model.menu_statuses.can_expand = can_expand
[docs]    @QtCore.pyqtSlot()
    def updateAnchorSelectionEnabled(self):
        aln = self.getAlignment()
        selected_res = aln.res_selection_model.getSelection()
        anchored_res = aln.getAnchoredResiduesWithRef()
        sel_anchored_res = selected_res.intersection(anchored_res)
        all_anchored = (len(sel_anchored_res) == len(selected_res))
        can_anchor_sel = (bool(len(selected_res)) and
                          aln._anchorSelectionValid() and not all_anchored)
        self.model.menu_statuses.can_anchor_res = can_anchor_sel
        self.model.menu_statuses.can_unanchor_res = bool(sel_anchored_res)
[docs]    @QtCore.pyqtSlot()
    def updateRemoveHighlightsEnabled(self):
        aln = self.getAlignment()
        highlight_map = aln.getHighlightColorMap()
        sel_res = aln.res_selection_model.getSelection()
        can_remove_highlights = any(res in highlight_map for res in sel_res)
        self.model.menu_statuses.can_remove_highlights = can_remove_highlights
[docs]    def updateHideColumnsEnabled(self):
        # TODO hiding isn't enabled yet
        # Enable if only full columns are selected
        pass
[docs]    @QtCore.pyqtSlot()
    def updateAlignSelectedSeqsEnabled(self):
        aln = self.getAlignment()
        selected_seqs = aln.seq_selection_model.getSelection()
        ref_seq = aln.getReferenceSeq()
        selected_seqs.discard(ref_seq)
        enable = len(aln) > 1 and len(selected_seqs) > 0
        self.model.menu_statuses.can_align_selected_seqs = enable
    @QtCore.pyqtSlot()
    def _updateClearConstraintsEnabled(self):
        aln = self.getAlignment()
        enable = aln.pairwise_constraints.hasConstraints()
        self.model.menu_statuses.can_clear_constraints = enable
    @QtCore.pyqtSlot()
    def _onAlnSetChanged(self):
        self._updateAlnSets()
        self._updateRemoveFromAlnSetEnabled()
        self._updateAlignAlnSetEnabled()
        self._updateResEditStatuses()
        self._updateEditSequenceAsTextEnabled()
    @QtCore.pyqtSlot()
    def _updateAlignAlnSetEnabled(self):
        can_only_multiple_align = False
        can_only_profile_align = False
        can_aln_set_align = True
        aln_mode = validate_align.get_aln_set_align_mode(self.getAlignment())
        if aln_mode is viewconstants.SeqAlnMode.Multiple:
            can_only_multiple_align = True
        elif aln_mode is viewconstants.SeqAlnMode.Profile:
            can_only_profile_align = True
        elif not aln_mode:
            can_aln_set_align = False
        statuses = self.model.menu_statuses
        statuses.can_only_multiple_align = can_only_multiple_align
        statuses.can_only_profile_align = can_only_profile_align
        statuses.can_aln_set_align = can_aln_set_align
    @QtCore.pyqtSlot()
    def _updateRemoveFromAlnSetEnabled(self):
        aln = self.getAlignment()
        selected_seqs = aln.seq_selection_model.getSelection()
        enable = any(aln.alnSetForSeq(seq) is not None for seq in selected_seqs)
        self.model.menu_statuses.can_remove_from_aln_set = enable
    def _updateAlnSets(self):
        """
        Update the list of alignment set names.  This list is used to populate
        "Alignment Set -> Add to Set" in the sequence context menu.
        """
        set_names = self.getAlignment().alnSetNames()
        if set_names != self.model.menu_statuses.aln_set_names:
            self.model.menu_statuses.aln_set_names = set_names
    @QtCore.pyqtSlot()
    def _updateCanAlignBindingSites(self):
        """
        Updates whether a binding site alignment can be run. Residues
        must be selected only on the reference structure and at least 3 residues
        must be selected.
        """
        aln = self.getAlignment()
        all_selected_res = aln.res_selection_model.getSelection()
        if len(all_selected_res) < 3:
            can_align = False
        else:
            ref_seq = aln.getReferenceSeq()
            selected_res_in_ref_seq = {
                res for res in all_selected_res if res.sequence == ref_seq
            }
            can_align = all_selected_res == selected_res_in_ref_seq
        self.model.menu_statuses.can_align_binding_site = can_align
    # ==========================================================================
    # Helpers
    # ==========================================================================
    def _getNewAlignment(self):
        """
        Return an alignment appropriate for the widget (ProteinAlignment, etc.)
        with a reference to the widget's undo stack set on it.
        :return: `schrodinger.application.msv.alignment.Alignment`
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "alignment here.")
        raise NotImplementedError(msg)
    def _getAlignmentView(self, undo_stack):
        """
        Return an alignment view appropriate for the widget.
        :param is_workspace: Whether this widget represents the workspace.
        :type is_workspace: bool
        :param undo_stack: The undo stack to pass to the view.
        :type undo_stack: schrodinger.application.msv.command.UndoStack
        :rtype: view.AbstractAlignmentView
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "alignment view here.")
        raise NotImplementedError(msg)
    def _getNoScrollBarView(self):
        """
        Return an alignment view without scroll bars
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "no scrollbar alignment view here.")
        raise NotImplementedError(msg)
    def _getAlignmentInfoView(self):
        """
        Return an alignment view for the alignment info columns on the left
        side
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "alignment info view here.")
        raise NotImplementedError(msg)
    def _getAlignmentMetricsView(self):
        """
        Return an alignment view for the "frozen" metrics columns on the right
        side
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "alignment metrics view here.")
        raise NotImplementedError(msg)
    def _getAlignmentViewModel(self):
        """
        Return an alignment viewmodel appropriate for the widget.
        """
        msg = ("Subclasses of AbstractMsvWidget must return an appropriate "
               "alignment viewmodel here.")
        raise NotImplementedError(msg)
    # ==========================================================================
    # External API
    # ==========================================================================
[docs]    @qt_utils.wait_cursor
    def generatePfam(self):
        aln = self.getAlignment()
        if aln.seq_selection_model.hasSelection():
            seqs = aln.seq_selection_model.getSelection()
        else:
            seqs = list(aln)
        if self._pfam_task_queue is None:
            task_queue = MsvTaskQueue(description="Finding Pfam sequences...",
                                      max_running_tasks=1)
            task_queue.queueDone.connect(self._onPfamFinished)
            self._pfam_task_queue = task_queue
        for seq in seqs:
            task = pfam.PfamTask()
            task.input.seq = seq
            self._pfam_task_queue.addTask(task)
        self.taskStarted.emit(self._pfam_task_queue)
        if self._pfam_task_queue.status != task.RUNNING:
            self._pfam_task_queue.start()
    @QtCore.pyqtSlot()
    def _onPfamFinished(self):
        num_failed = 0
        for task in self._pfam_task_queue.getTasks():
            if task.status is task.DONE:
                task.input.seq.setPfam(task.output.pfam, task.output.name)
            elif task.status is task.FAILED:
                num_failed += 1
        self._pfam_task_queue = None
        if num_failed:
            p = inflect.engine()
            self.warning(f"Pfam failed for {p.no('sequence', num_failed)}")
        self.model.options.predicted_annotations.add(SEQ_ANNO_TYPES.pfam)
[docs]    def generatePredictionAnnotation(self, anno):
        """
        Generate predictions for the given prediction annotation. `anno`
        should be a key in `PRED_ANNO_TO_PRED_FUNC`.
        """
        self._runPredictionsForSelectedSeqs([anno])
[docs]    def generateAllPredictions(self):
        annos = list(PRED_ANNO_TO_PRED_FUNC)
        self._runPredictionsForSelectedSeqs(annos)
    def _runPredictionsForSelectedSeqs(self, annos):
        selected_seqs = self.model.split_aln.getSelectedSequences()
        if not selected_seqs:
            self.warning("Select a sequence to generate predictions for.")
            return
        for seq in selected_seqs:
            self._runPredictionsForSeq(seq, annos)
    def _runPredictionsForSeq(self, seq, annos):
        blast_task = self._runBlastForPredictions(seq)
        if blast_task.status is not blast_task.DONE:
            return
        blast_aln = blast_task.getBlastAlignment()
        for anno in annos:
            if (anno is SEQ_ANNO_TYPES.pred_disulfide_bonds and
                    sum(str(r) == 'C' for r in seq.residues()) < 2):
                continue
            pred_task = predictors.PredictorWrapperTask(anno, seq, blast_aln)
            self.taskStarted.emit(pred_task)
            pred_task.start()
            pred_task.wait()  # TODO PANEL-18317
            self.model.options.predicted_annotations.add(anno)
    def _runBlastForPredictions(self, seq, location=blast.LOCAL):
        blast_task = blast.BlastTask()
        blast_task.input.settings.location = location
        blast_task.input.query_sequence = seq
        self.startPredictorBlastTaskRequested.emit(blast_task)
        return blast_task
[docs]    def resetUndoStack(self):
        """
        Reset the undo stack
        This needs to be called when we perform an operation that gets tangled
        up with Maestro's single step undo, so that permitting an undo from
        msv would result in data corruption.
        """
        self.undo_stack.clear()
[docs]    def anchorSelection(self):
        """
        Anchors all unanchored residues in the selection
        """
        self.view.anchorSelection()
[docs]    def clearAnchored(self):
        """
        Removes anchors from all anchored residues in the selection
        """
        self.view.unanchorSelection()
[docs]    def getAlignment(self):
        """
        Returns the widget's alignment instance
        :rtype: `schrodinger.protein.alignment.BaseAlignment`
        :return: The widget's alignment
        """
        return self.model.aln
[docs]    def importSeq(self, seq, index=None):
        """
        Adds a given sequence to the widget's alignment and align if the widget
        already had one or more sequences
        :param seq: The sequence to add
        :type seq: schrodinger.protein.sequence.ProteinSequence
        :param index: Index to import the seq at
        :type index: int or None
        """
        self.importSeqs([seq], index)
[docs]    def importSeqs(self, seqs, index=None, replace_selection=True):
        """
        Add sequences to the widget's alignment and align if the widget already
        had one or more sequences.
        .. WARNING::
            Avoid calling this method from unittests as it adds a maestro
            test-time dependency
        :param seqs: Sequences to add
        :type  seqs: iterable(schrodinger.protein.sequence.ProteinSequence)
        :param index: Index to import the seqs at (must be 1 or greater)
        :type index: int or None
        """
        if self.isWorkspace():
            raise RuntimeError("Cannot add sequences to the workspace widget.  "
                               "Add entries to the workspace instead.")
        if index == 0:
            raise RuntimeError("Cannot import sequence as the reference.")
        aln = self.getAlignment()
        prev_n_seqs = len(aln)
        seqs = list(seqs)
        aln.addSeqs(seqs, index=index, replace_selection=replace_selection)
        if self.options_model.auto_align:
            if not self.model.split_chain_view:
                # In combined-chain mode, we need to get the new combined-chain
                # sequences from the alignment since seqs contains split-chain
                # sequences
                seqs = aln[prev_n_seqs:]
                if not seqs:
                    # If all of the chains got added to existing combined-chain
                    # sequences, then there's nothing to align
                    return
            n_new_seqs = len(seqs)
            had_ref = bool(prev_n_seqs)
            if had_ref:
                should_align = True
                if n_new_seqs >= 20:
                    dlg = dialogs.TooManyAutoAlignDialog(n_new_seqs, self)
                    should_align = dlg.exec_()
                if should_align:
                    self._alignNewSeqs(seqs, pairwise=prev_n_seqs > 1)
    def _alignNewSeqs(self, new_seqs, pairwise=True):
        """
        :param pairwise: Whether to pairwise align each new sequence to the
            reference sequence or multiple align the entire alignment
        :type  pairwise: bool
        """
        aln = self.getAlignment()
        n_seqs = len(aln)
        if n_seqs < 2:
            raise RuntimeError("Can't align fewer than 2 seqs")
        with aln.seq_selection_model.suspendSelection(), \
                aln.res_selection_model.suspendSelection():
            if pairwise:
                self.setSelectedSequences(new_seqs)
                undo_desc = ("(Auto) Pairwise Alignment with Locked Reference "
                             " Gaps")
                with command.compress_command(self.undo_stack, undo_desc):
                    self.mergedPairwiseAlignment()
                    aln.minimizeAlignment()
            else:
                undo_desc = ("(Auto) Multiple Alignment")
                with command.compress_command(self.undo_stack, undo_desc):
                    self.multipleAlignment()
    def _syncWsResSelectionForSeqs(self, seqs):
        """
        Synchronize residue selection with the workspace for sequences that were
        just added to the alignment.
        :param seqs: The sequences to select residues in
        :type seqs: Iterable(sequence.Sequence)
        """
        if self.isWorkspace():
            # Residue selection synchronization for the workspace tab is
            # handled inside the structure model.
            # This early return also prevents a crash due to MAE-44891.
            return
        aln = self.getAlignment()
        self._structure_model.applyWorkspaceSelectionToSeqs(aln, seqs)
    def _checkCanDeleteSeqs(self) -> bool:
        aln = self.getAlignment()
        sel_seqs = aln.getSelectedSequences()
        ref_seq = aln.getReferenceSeq()
        if ref_seq in sel_seqs:
            if len(sel_seqs) == len(aln):
                return self.question("Delete all sequences?")
            else:
                anchor_text = ""
                if aln.getAnchoredResidues():
                    anchor_text = "remove all anchors and "
                return self.question(
                    f"Deleting the Reference sequence will {anchor_text}cause "
                    "the first available sequence to become the Reference. "
                    "Continue anyway?",
                    title="Reference Sequence Selected")
        return True
[docs]    @QtCore.pyqtSlot()
    def removeSelectedSeqs(self):
        """
        Remove the currently selected sequences from the alignment.
        """
        aln = self.getAlignment()
        sel_seqs = aln.getSelectedSequences()
        if not sel_seqs:
            return
        self.removeSequences(sel_seqs)
[docs]    def removeSequences(self, seqs):
        """
        Remove the specified sequences. Displays the new reference sequence in
        case it is hidden.
        :param seqs: List of sequences to be removed
        :type seqs: list(sequence.Sequence)
        """
        if self.isWorkspace():
            raise RuntimeError("Cannot remove sequences from the workspace "
                               "widget.  Remove entries from the workspace "
                               "instead.")
        aln = self.getAlignment()
        ref_seq = aln.getReferenceSeq()
        if ref_seq in seqs:
            if self._checkCanDeleteSeqs():
                n_seqs = len(seqs)
                if n_seqs == len(aln):
                    desc = "Remove All Sequences"
                elif n_seqs == 1:
                    desc = "Remove Reference Sequence"
                else:
                    n_others = n_seqs - 1
                    other_text = inflect.engine().plural("Other", n_others)
                    desc = "Remove Reference Sequence and " \
                            f"{n_others} {other_text}"
                with command.compress_command(self.undo_stack, desc):
                    # Unhide the next available reference sequence,
                    # if it is hidden.
                    if aln.anyHidden():
                        nonref_seqs = itertools.islice(aln, 1, None)
                        new_ref_seq = next(
                            (seq for seq in nonref_seqs if seq not in seqs),
                            None)
                        if (new_ref_seq is not None and
                                aln.isSeqHidden(new_ref_seq)):
                            aln.showSeqs([new_ref_seq])
                    aln.clearAnchors()
                    aln.removeSeqs(seqs)
        else:
            aln.removeSeqs(seqs)
    def _checkCanSetReferenceSeq(self) -> bool:
        """
        If there are anchors, prompt user before changing reference seq
        """
        if not self.getAlignment().getAnchoredResidues():
            return True
        return self.question(
            "Changing the Reference sequence will remove all anchors. "
            "Continue anyway?",
            title="Anchors Present")
[docs]    @QtCore.pyqtSlot()
    def setSelectedSeqAsReference(self):
        """
        Set the selected sequence as the reference sequence. If more than
        one sequence is selected, no reference sequence will be set. Restore the
        expanded state of each sequence afterwards, because it will be reset
        after the reordering of the rows.
        """
        sel_seqs = self.getSelectedSequences()
        if len(sel_seqs) != 1 or self.getAlignment().isReferenceSeq(
                sel_seqs[0]):
            return
        if self._checkCanSetReferenceSeq():
            new_ref_seq = sel_seqs[0]
            desc = f"Set {new_ref_seq.fullname} as Reference Sequence"
            with command.compress_command(self.undo_stack, desc):
                self.getAlignment().clearAnchors()
                self._setReferenceSeq(new_ref_seq)
    def _setReferenceSeq(self, seq):
        """
        Set the specified sequence as the reference sequence. Restore the
        expanded state of each sequence afterwards, because it will be reset
        after the reordering of the rows.
        :param seq: The sequence to set as the reference seq
        :type seq: schrodinger.protein.sequence.ProteinSequence
        """
        self.getAlignment().setReferenceSeq(seq)
[docs]    @QtCore.pyqtSlot()
    def selectResForSeqs(self):
        """
        Select all residues in the alignment for the selected sequences
        """
        self.getAlignment().setResSelectionStateForSelectedSeqs(True)
[docs]    @QtCore.pyqtSlot()
    def deselectResForSeqs(self):
        """
        Clears the selection of residues for the selected sequences.
        """
        self.getAlignment().setResSelectionStateForSelectedSeqs(False)
[docs]    @QtCore.pyqtSlot()
    def blastSelectedSeq(self):
        """
        Set selected sequence as reference sequence and open BLAST search dialog.
        """
        self.setSelectedSeqAsReference()
        self.openBlastSearchDialog()
[docs]    def expandSelectionAlongCols(self):
        """
        Expand selection based on current residue selection to include all
        residues in their columns.
        """
        self.getAlignment().expandSelectionAlongColumns()
[docs]    def expandSelectionReferenceOnly(self):
        """
        Select column residues for all columns that have a non-gap reference
        """
        self.getAlignment().expandSelectionFromReference()
[docs]    def selectIdentityColumns(self):
        """
        Select the residues in the identity columns of the alignment
        """
        self.getAlignment().selectIdentityColumns()
[docs]    def selectAlignedResidues(self):
        """
        Selects residues in columns containing no gaps.
        """
        self.getAlignment().selectAlignedResidues()
[docs]    def openBlastSearchDialog(self):
        """
        Implement sequence-type-specific BLAST dialog in child classes
        """
[docs]    @QtCore.pyqtSlot(object)
    def moveSelectedSequences(self, direction):
        """
        Move the currently selected sequences in the specified direction.
        Will prompt the user if there are hidden seqs and the direction is Up
        or Down.
        :param direction: Direction to move items.
        :type  direction: viewconstants.Direction
        :raise ValueError: Invalid value of `direction`.
        :raise IndexError: `seq_list` is not a subset of self.
        """
        if direction in (viewconstants.Direction.Down,
                         viewconstants.Direction.Up):
            aln = self.getAlignment()
            hidden_seqs = dialogs.prompt_for_hidden_seqs(self, aln)
            if hidden_seqs:
                return
        self._moveSequences(self.getSelectedSequences(), direction)
    def _moveSequences(self, seq_list, direction):
        aln = self.getAlignment()
        if len(aln) == 0:
            return
        non_query_items = list(range(1, len(aln)))
        seq_indices = (
            aln.index(seq) for seq in seq_list if not aln.isReferenceSeq(seq))
        new_seq_ordering = self.moveItems(all_items=non_query_items,
                                          sel_items=sorted(seq_indices),
                                          direction=direction)
        # Add query back to first position
        new_seq_ordering = [0] + new_seq_ordering
        aln.reorderSequences(new_seq_ordering)
[docs]    @classmethod
    def moveItems(cls, all_items, sel_items, direction):
        """
        Move the specified items in the specified direction.
        :param all_items: List of unique items.
        :type  all_items: list
        :param sel_items: Items to move; must be a subset of `all_items`.
        :type  sel_items: list
        :param direction: Direction to move items.
        :type  direction: viewconstants.Direction
        :return: Reordered items
        :rtype: list
        :raise ValueError: Invalid value of `direction` or `sel_items` is not a
            subset of `all_items`.
        """
        all_items = list(all_items)  # Copy to avoid modifying original
        sel_items = list(sel_items)  # Cast to list for comparison
        all_item_set = set(all_items)
        if len(all_item_set) != len(all_items):
            raise ValueError("`all_items` is not unique")
        sel_item_set = set(sel_items)
        if len(sel_item_set) != len(sel_items):
            raise ValueError("`sel_items` is not unique")
        if not sel_item_set.issubset(all_item_set):
            raise ValueError("`sel_items` is not a subset of `all_items`")
        DIRECTION = viewconstants.Direction
        last_idx = len(all_items) - 1
        # Exit early if sel_items are already at the top or bottom
        if ((direction is DIRECTION.Top or direction is DIRECTION.Up) and
                all_items[:len(sel_items)] == sel_items) or (
                    (direction is DIRECTION.Bottom or
                     direction is DIRECTION.Down) and
                    all_items[-len(sel_items):] == sel_items):
            return all_items
        if direction is DIRECTION.Top:
            iterable = reversed(sel_items)
            idx_func = lambda idx: 0
        elif direction is DIRECTION.Up:
            iterable = sel_items
            idx_func = lambda idx: max(0, idx - 1)
        elif direction is DIRECTION.Down:
            iterable = reversed(sel_items)
            idx_func = lambda idx: min(last_idx, idx + 1)
        elif direction is DIRECTION.Bottom:
            iterable = sel_items
            idx_func = lambda idx: last_idx
        else:
            raise ValueError("Invalid value for 'direction'")
        for itm in iterable:
            idx = all_items.index(itm)
            new_idx = idx_func(idx)
            if new_idx != idx:
                all_items.insert(new_idx, all_items.pop(idx))
        return all_items
    @QtCore.pyqtSlot(object)
    def _onResidueMiddleClicked(self, res):
        """
        Fit to clicked residue if MSV is opened from Maestro.
        :param res: clicked residue
        :type res: protein.residue.Residue
        """
        if maestro:
            self._structure_model.onResidueMiddleClicked(res)
[docs]    def getShownRowTypes(self):
        """
        Returns a list of enums representing the shown row types
        :rtype: list
        :return: A list of enums
        """
        return self._table_model.getShownRowTypes()
[docs]    def rowWrap(self):
        """
        Indicates whether rows are currently wrapped
        :rtype: bool
        :return: Whether the rows are currently wrapped
        """
        return self._table_model.rowWrap()
[docs]    def groupedByType(self):
        """
        Indicates whether rows are grouped by type
        :rtype: bool
        :return: Whether the rows are currently grouped by type
        """
        by_ann = viewconstants.GroupBy.Type
        return by_ann is self._table_model.getGroupBy()
[docs]    @QtCore.pyqtSlot()
    def updateColorsEnabled(self):
        """
        Set whether to color all sequences from the options model
        """
        colors_enabled = self.options_model.colors_enabled
        if colors_enabled:
            self.updateSeqColorScheme()
        else:
            no_colors = color.NoColorScheme()
            self.applyColorScheme(no_colors)
[docs]    @QtCore.pyqtSlot()
    def updateSeqColorScheme(self):
        """
        Set the sequence color scheme from the options model.
        """
        scheme = self.options_model.seq_color_scheme
        self.applyColorScheme(scheme)
        if isinstance(scheme, color.WorkspaceScheme):
            self.pullWorkspaceColors()
[docs]    def showColorSchemeEditor(self):
        # make sure that colors are enabled so the user can see the colors as
        # they edit them and so that we don't send NoColorScheme to the color
        # editor dialog.
        if not self.options_model.colors_enabled:
            self.options_model.colors_enabled = True
        seq_scheme = self.getColorScheme()
        color_editor = dialogs.ColorEditor(seq_scheme,
                                           self.model.aln,
                                           parent=self)
        color_editor.schemeChanged.connect(self._previewColorScheme)
        ok_pressed = color_editor.exec()
        if ok_pressed:
            self.applyColorScheme(color_editor.getCurrentScheme())
        else:
            self._restoreColorScheme()
[docs]    def setSequenceExpansionState(self, sequences=None, expand=True):
        """
        Set the expansion state for the given (or all shown) sequences.
        :param sequences: Sequences to expand or collapse. If None, use all
            shown sequences.
        :type sequences: list(Sequence)
        :param expand: Whether to expand the sequences
        :type expand: bool
        """
        if sequences is None:
            aln = self.getAlignment()
            if aln.anyHidden():
                sequences = aln.getShownSeqs()
            else:
                func = self.view.expandAll if expand else self.view.collapseAll
                func()
                return
        self.view.setSequenceExpansionState(sequences, expand)
[docs]    @QtCore.pyqtSlot()
    def translateSelectedSequences(self):
        """
        Translate the selected nucleic acid sequences
        """
        sel_seqs = self.getSelectedSequences()
        if not sel_seqs:
            return
        if self.isWorkspace():
            self.translateIntoNewTabRequested.emit(sel_seqs)
            return
        self.translateSeqsInPlace(sel_seqs)
[docs]    def translateSeqsInPlace(self, seqs):
        """
        Translate the given sequences in place.
        :param seqs: The sequencs to translate
        :type seqs: iterable[sequence.NucleicAcidSequence]
        """
        msg = "Cannot translate in place on Workspace Tab"
        assert not self.isWorkspace(), msg
        seqs = [
            seq for seq in seqs if isinstance(seq, sequence.NucleicAcidSequence)
        ]
        if not seqs:
            return
        aln = self.getAlignment()
        desc = "Translate RNA/DNA Sequences"
        with command.compress_command(self.undo_stack, desc):
            for source_seq in seqs:
                if not isinstance(source_seq, sequence.NucleicAcidSequence):
                    continue
                seq_idx = aln.index(source_seq)
                protein_seq = source_seq.getTranslation()
                self.importSeq(protein_seq, seq_idx + 1)
            self.removeSequences(seqs)
    # ==========================================================================
    # Command Interface
    # ==========================================================================
    # The role of the methods in this section is to create and return commands.
    # The main work here is in gathering the parameters with which to create
    # the command objects. Actual work is performed elsewhere when the command
    # object calls the gui with the appropriate parameters.
    # Operations that modify sequences and alignments do NOT need to be
    # performed within a command, because they use the widget's own command
    # stack directly.
[docs]    @util.skip_if("_changing_options_model")
    @command.do_command()
    def applyColorScheme(self, scheme):
        """
        Set the sequence row color scheme.
        :param scheme: The color scheme to be applied.
        :type: color.AbstractRowColorScheme
        """
        if self._curr_color_scheme is not None:
            original_scheme = self._curr_color_scheme
        else:
            original_scheme = self.getColorScheme()
        redo = partial(self._applyColorScheme, scheme)
        undo = partial(self._applyColorScheme, original_scheme)
        desc = "Apply Color Scheme"
        return redo, undo, desc
[docs]    def setSelectedResColor(self, color):
        """
        Set the highlight color of the selected residues and enable colors
        """
        self.options_model.colors_enabled = True
        self.getAlignment().setSelectedResColor(color)
[docs]    def clearAllHighlights(self):
        self.options_model.colors_enabled = True
        self.getAlignment().clearAllHighlights()
[docs]    @QtCore.pyqtSlot()
    def updateRowWrap(self):
        """
        Set the row wrapping on the widget
        """
        self._table_model.setRowWrap(self.options_model.wrap_sequences)
        self.h_scrollbar.setVisible(not self.options_model.wrap_sequences)
[docs]    @prompt_if_seqs_hidden
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(object, bool)
    def sortBy(self, sort_by, reverse=False):
        """
        Sort the alignment by the specified criteria.
        :param sort_by: The criterion to sort on
        :type sort_by: viewconstants.SortTypes
        :param reverse: Whether to sort in reverse (descending) order.
        :type order: bool
        """
        metric_funcs = {
            viewconstants.SortTypes.Identity: 'getIdentity',
            viewconstants.SortTypes.Similarity: 'getSimilarity',
            viewconstants.SortTypes.Conservation: 'getConservation',
            viewconstants.SortTypes.Score: 'getSimilarityScore',
        }
        def get_metric_func(sort_by, reference, consider_gaps):
            def metric_func(seq):
                func_name = metric_funcs[sort_by]
                func = getattr(seq, func_name)
                return func(reference, consider_gaps=consider_gaps)
            return metric_func
        sorts_without_ref = {
            viewconstants.SortTypes.Name: lambda seq: seq.name,
            viewconstants.SortTypes.ChainID: lambda seq: seq.chain,
            viewconstants.SortTypes.NumGaps: lambda seq: len(seq.getGaps()),
            viewconstants.SortTypes.Length: lambda seq: len(seq),
        }
        aln = self.getAlignment()
        if aln is not None:
            if isinstance(sort_by, properties.SequenceProperty):
                aln.sortByProperty(sort_by, reverse=reverse)
            elif sort_by in metric_funcs:
                reference_seq = self.getReferenceSeq()
                consider_gaps = self.options_model.include_gaps
                sort_func = get_metric_func(sort_by, reference_seq,
                                            consider_gaps)
                aln.sort(key=sort_func, reverse=reverse)
            else:
                aln.sort(key=sorts_without_ref[sort_by], reverse=reverse)
    # ==========================================================================
    # Command Implementations (only called from within commands)               #
    # ==========================================================================
    @command.from_command_only
    def _applyColorScheme(self, scheme):
        """
        Apply a new color scheme. Automatically discards any color scheme
        that is currently being previewed.
        """
        self._restoreColorScheme()
        self._table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                            scheme)
        # Update the appropriate color scheme in the options model. The
        # options model change will trigger the command again, so we use
        # a context manager to avoid that.
        with self._changingOptionsModel():
            if scheme.custom:
                self.options_model.custom_color_scheme = scheme
            if isinstance(scheme, color.NoColorScheme):
                self.options_model.colors_enabled = False
            else:
                self.options_model.colors_enabled = True
                self.options_model.seq_color_scheme = scheme
    @QtCore.pyqtSlot(color.AbstractRowColorScheme)
    def _previewColorScheme(self, scheme):
        """
        Set a color scheme for sequences without adding it to the undo
        stack. Calling `_restoreColorScheme` will remove the preview.
        """
        if self._curr_color_scheme is None:
            self._curr_color_scheme = self.getColorScheme()
        self._table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                            scheme)
    def _restoreColorScheme(self):
        """
        Remove any color scheme that's currently being previewed.
        """
        if self._curr_color_scheme is None:
            return
        scheme = self._curr_color_scheme
        self._table_model.updateColorScheme(viewconstants.RowType.Sequence,
                                            scheme)
        self._curr_color_scheme = None
    @QtCore.pyqtSlot()
    def _updateSeqStatusModel(self):
        """
        Update all parts of status model
        """
        aln = self.getAlignment()
        self.seq_status_model.num_total = len(aln)
        self.seq_status_model.num_hidden = sum(
            1 for shown in aln.getSeqShownStates() if not shown)
        self._updateColorScheme()
        self._updateStatusModelRefSeq()
        self._updateStatusModelNumSelected()
        self._updateStatusModelNumStructs()
    @QtCore.pyqtSlot()
    def _updateCanDeleteAllPredictions(self):
        aln = self.getAlignment()
        model = self.model
        def has_prediction(seq):
            return any([
                seq.hasDisorderedRegionsPredictions(),
                seq.hasDisulfideBondPredictions(),
                seq.hasDomainArrangementPredictions(),
                seq.hasSolventAccessibility(),
                seq.hasSSAPredictions()
            ])
        model.menu_statuses.can_delete_predictions = any(
            has_prediction(seq) for seq in aln)
    @QtCore.pyqtSlot()
    def _updateColorScheme(self):
        """
        Called when the widget's alignment length changes.
        """
        color_scheme = self.getColorScheme()
        if isinstance(color_scheme, color.PositionScheme):
            max_len = self.getAlignment().num_columns
            color_scheme.setLength(max_len)
        if isinstance(color_scheme,
                      (color.PositionScheme, color.SimilarityScheme)):
            # We are working with a copy of the original scheme, so we
            # must re-apply it here.
            self.applyColorScheme(color_scheme)
    @QtCore.pyqtSlot()
    def _updateStatusModelRefSeq(self):
        ref_seq = self.getReferenceSeq()
        name = format_seq_name_with_chain(ref_seq, self.model.split_chain_view)
        self.seq_status_model.ref_seq = name
    @QtCore.pyqtSlot()
    def _updateStatusModelNumSelected(self):
        self.seq_status_model.num_selected = len(self.getSelectedSequences())
    @QtCore.pyqtSlot()
    def _updateStatusModelNumStructs(self):
        seq_counts = self.getAlignment().getWorkspaceCounts()
        num_in_ws = sum(
            seq_counts[visibility]
            for visibility, included in viewconstants.included_map.items()
            if included)
        num_total = sum(count for visibility, count in seq_counts.items()
                        if visibility is not None)
        self.seq_status_model.num_structs.num_in_ws = num_in_ws
        self.seq_status_model.num_structs.num_total = num_total
    @QtCore.pyqtSlot()
    def _updateResStatusAndEditStatus(self):
        """
        Update residue statuses and the edit toolbar.
        Should be called when residue selection changes or the alignment
        changes in ways that affect the shape of the residue selection (e.g.
        sequences reordered, split chain toggled)
        """
        self._updateResStatusModel()
        self._updateResEditStatuses()  # Uses res status model
    def _updateResStatusModel(self):
        """
        Store numbers of residues and blocks selected in the res status model
        """
        aln = self.getAlignment()
        res_sel_mdl = aln.res_selection_model
        selected_elements = res_sel_mdl.getSelection()
        if not selected_elements:
            self.res_status_model.reset()
            return
        n_res_selected = 0
        n_gaps_selected = 0
        seqs_with_selected_elems = set()
        for elem in selected_elements:
            seqs_with_selected_elems.add(elem.sequence)
            if elem.is_gap:
                n_gaps_selected += 1
            else:
                n_res_selected += 1
        status_model = self.res_status_model
        status_model.num_res_selected = n_res_selected
        status_model.num_gaps_selected = n_gaps_selected
        status_model.num_seqs_with_selected_elems = len(
            seqs_with_selected_elems)
        status_model.num_blocks_selected = res_sel_mdl.numBlocksSelected()
    def _updateResEditStatuses(self):
        """
        Set menu statuses related to editing residues. Uses data set on
        `self.res_status_model`.
        """
        statuses = self._getResEditStatuses()
        for key, value in statuses.items():
            setattr(self.model.menu_statuses, key, value)
    def _getResEditStatuses(self):
        """
        Return a dict of statuses related to editing residues. Uses data set on
        `self.res_status_model`.
        """
        aln = self.getAlignment()
        res_sel_mdl = aln.res_selection_model
        statuses = {
            'can_copy_residues': False,
            'can_delete_gaps': False,
            'can_insert_gap': False,
            'can_insert_residues': False,
            'can_delete_residues': False,
            'can_replace_res_with_gaps': False,
            'can_replace_selected_elems': False,
            'can_change_elem': False,
        }
        has_selection = res_sel_mdl.hasSelection()
        if not has_selection:
            # If no residues are selected, can't do any edit actions
            return statuses
        res_status_mdl = self.res_status_model
        single_block_single_seq = (res_status_mdl.num_blocks_selected == 1 and
                                   res_status_mdl.num_seqs_with_selected_elems
                                   == 1)
        # Can always copy selected residues
        statuses['can_copy_residues'] = single_block_single_seq
        if aln.alnSetResSelected():
            # Aln sets can't be edited (even to insert gaps)
            return statuses
        statuses['can_delete_gaps'] = res_status_mdl.num_gaps_selected > 0
        statuses['can_insert_gap'] = has_selection
        if self.isWorkspace():
            # On the workspace tab, can only edit gaps
            return statuses
        statuses['can_insert_residues'] = single_block_single_seq
        # Structured residues can't be edited
        structured = res_sel_mdl.anyStructuredResiduesSelected()
        can_delete_residues = not structured
        statuses['can_delete_residues'] = can_delete_residues
        statuses['can_replace_res_with_gaps'] = can_delete_residues
        statuses['can_replace_selected_elems'] = (not structured and
                                                  single_block_single_seq)
        single_res = (res_status_mdl.num_res_selected +
                      res_status_mdl.num_gaps_selected == 1)
        statuses['can_change_elem'] = single_res and not structured
        return statuses
    @QtCore.pyqtSlot(bool)
    def _updateResOutlineState(self, enabled):
        self.view.setResOutlinesShown(enabled)
[docs]    def saveImage(self):
        """
        Saves multiple sequence alignment as an image file, by default,
        exports image of the entire alignment.
        """
        if self._table_model.export_model.rowCount() == 0:
            self.warning(title="No Alignment",
                         text="Alignment is currently empty.")
            return
        accepted = self.image_export_dialog.exec_()
        if not accepted:
            return
        model = self.image_export_dialog.model
        logo_was_on = (ALN_ANNO_TYPES.sequence_logo
                       in self._table_model.getShownRowTypes())
        disconnect_after_print = False
        visible_scrollbar = self.v_scrollbar.isVisible()
        if model.export_type == model.ExportTypes.ENTIRE_ALN:
            disconnect_after_print = True
            view_to_print, tmp_info, tmp_metrics = self._createNoScrollView()
        elif model.export_type == model.ExportTypes.VISIBLE_ALN:
            if visible_scrollbar:
                self.v_scrollbar.setVisible(False)
                # Needed to resize view after hiding scrollbar
                application.process_events()
            view_to_print = self
        else:  # sequence logo
            self._table_model._setVisibilityForRowType(
                ALN_ANNO_TYPES.sequence_logo, True)
            view_to_print = self.getLogoView()
        renderer = get_svg_renderer(view_to_print)
        if visible_scrollbar:
            self.v_scrollbar.setVisible(True)
        # the following makes sure ext is always in lower case.
        file_name = self.image_export_dialog.selectedFile()
        name, _ = os.path.splitext(file_name)
        file_name = f"{name}.{model.format.name.lower()}"
        if model.format == model.Format.PNG:
            save_png(renderer, file_name, model.dpi)
        elif model.format == model.Format.PDF:
            save_pdf(renderer, file_name)
        else:
            raise RuntimeError(
                'Attempting to export to an unsupported file format: %s.' %
                (model.format.name.lower()))
        self._table_model._setVisibilityForRowType(ALN_ANNO_TYPES.sequence_logo,
                                                   logo_was_on)
        # MSV-1883, MSV-1884 - The python wrappers for these widgets do not
        # get fully garbage collected when the signals remain connected, so
        # we must manually disconnect them here.
        if disconnect_after_print:
            for _view in [tmp_info, tmp_metrics]:
                _view.setModel(None)
[docs]    def getLogoView(self):
        """
        Return an alignment view which contains the rulers and sequence logo.
        :return: the logo view
        :rtype: view.LogoAlignmentView
        """
        model = viewmodel.ExportLogoProxyModel()
        model.setSourceModel(self._table_model.top_model)
        logo_view = view.LogoAlignmentView(self)
        logo_view.setLightMode(self._light_mode)
        logo_view.setModel(model)
        logo_view.setFrameStyle(QtWidgets.QFrame.NoFrame)
        return logo_view
    def _createNoScrollView(self):
        """
        Copy the views (we can't use the originals because views can't be
        used in 2 or more layouts at the same time)
        :return: 3-tuple of the no-scroll view widget, the copied info view
                 and the copied metrics view.
        :rtype: tuple(view.NoScrollAlignmentView, view.AlignmentInfoView,
                view.AlignmentMetricsView)
        """
        noscroll_view = self._getNoScrollAlignmentView()
        noscroll_view.setModel(self._table_model.export_model)
        noscroll_view.setFrameStyle(QtWidgets.QFrame.NoFrame)
        metrics = copy.copy(self.aln_metrics_view)
        info = copy.copy(self.aln_info_view)
        info.setMinimumWidth(self.aln_info_view.width())
        views = [info, noscroll_view, metrics]
        # Create the layout and add the views
        noscroll_view_widget = QtWidgets.QWidget(self)
        # give same object name as self.view_widget so it inherits style
        noscroll_view_widget.setObjectName("view_widget")
        noscroll_view_widget.setLayout(QtWidgets.QHBoxLayout())
        noscroll_view_widget.layout().setContentsMargins(0, 0, 0, 0)
        noscroll_view_widget.layout().setSpacing(0)
        noscroll_view_widget.setSizePolicy(QtWidgets.QSizePolicy.Ignored,
                                           QtWidgets.QSizePolicy.Ignored)
        for v in views:
            v.setLightMode(self._light_mode)
            noscroll_view_widget.layout().addWidget(v)
        # Calculate the dimensions needed to fit all the views
        # Use the original info view width since the copy hasn't had time
        # to update.
        layout_w = (self.aln_info_view.size().width() +
                    noscroll_view.size().width() + metrics.size().width())
        layout_h = max(noscroll_view.size().height(), metrics.size().height())
        layout_h += 10  # give a little extra padding to bottom
        noscroll_view_widget.resize(layout_w, layout_h)
        # Force resize event
        noscroll_view_widget.layout().update()
        noscroll_view_widget.layout().activate()
        return noscroll_view_widget, info, metrics
[docs]    def timeScrolling(self, only_horizontal=False, print_to_stdout=True):
        """
        Scroll the alignment and print how long it takes to the terminal.
        :param only_horizontal: If True, the alignment will only be scrolled
            horizontally.  If False, the alignment will be scrolled both
            vertically and horizontally.
        :type only_horizontal: bool
        :param print_to_stdout: Whether to print a message when scrolling is
            starting and ending.
        :type print_to_stdout: bool
        :return: The time (in seconds) to scroll the alignment.
        :rtype: float
        """
        app = QtWidgets.QApplication.instance()
        if print_to_stdout:
            print("Timing scrolling...")
        start_time = time.perf_counter()
        if not only_horizontal:
            vscroll = self.view.verticalScrollBar()
            last_value = None
            while vscroll.value() != last_value:
                last_value = vscroll.value()
                self._processEventsMultiple(app)
                vscroll.triggerAction(vscroll.SliderSingleStepAdd)
                self._processEventsMultiple(app)
                self.view.update()
                self._processEventsMultiple(app)
            while vscroll.value() > 0:
                vscroll.triggerAction(vscroll.SliderSingleStepSub)
                self.view.update()
                self._processEventsMultiple(app)
        hscroll = self.view.horizontalScrollBar()
        last_value = None
        while hscroll.value() != last_value:
            last_value = hscroll.value()
            hscroll.triggerAction(hscroll.SliderSingleStepAdd)
            self.view.update()
            self._processEventsMultiple(app)
        while hscroll.value() > 0:
            hscroll.triggerAction(hscroll.SliderSingleStepSub)
            self.view.update()
            self._processEventsMultiple(app)
        end_time = time.perf_counter()
        scroll_time = end_time - start_time
        if print_to_stdout:
            print("scroll time =", scroll_time)
        return scroll_time
[docs]    def timeScrollingByPage(self):
        """
        Scroll the alignment and print how long it takes to the terminal.
        Scrolling is only done downwards and rightwards so that no cached data
        is used.
        """
        app = QtWidgets.QApplication.instance()
        print("Timing scrolling...")
        start_time = time.perf_counter()
        vscroll = self.view.verticalScrollBar()
        vmax = vscroll.maximum()
        while vscroll.value() < vmax:
            vscroll.triggerAction(vscroll.SliderPageStepAdd)
            self.view.update()
            self._processEventsMultiple(app)
        hscroll = self.view.horizontalScrollBar()
        hmax = hscroll.maximum()
        while hscroll.value() < hmax:
            hscroll.triggerAction(hscroll.SliderPageStepAdd)
            self.view.update()
            self._processEventsMultiple(app)
        end_time = time.perf_counter()
        print("scroll time =", end_time - start_time)
    def _processEventsMultiple(self, app):
        """
        Call processEvents multiple times in case any pending timer slots
        trigger additional timers.
        :param app: The Qt application
        :type app: QtWidgets.QApplication
        """
        for _ in range(25):
            app.processEvents()
[docs]    def profileScrolling(self):
        """
        Scroll the alignment while recording profiling data.  Returns
        immediately.
        """
        QtCore.QTimer.singleShot(0, self.profileScrollingBlocking)
[docs]    def profileScrollingBlocking(self,
                                 only_horizontal=False,
                                 print_to_stdout=True,
                                 filename=None):
        """
        Scroll the alignment while recording profiling data.  Returns when
        scrolling is complete.
        :param only_horizontal: If True, the alignment will only be scrolled
            horizontally.  If False, the alignment will be scrolled both
            vertically and horizontally.
        :type only_horizontal: bool
        :param print_to_stdout: Whether to print the filename, the measured
            time, and a message when scrolling is starting and ending.
        :type print_to_stdout: bool
        :param filename: The file to save profiling data to.  If not given, a
            filename will be generated using the current date and time.
        :type filename: str or None
        :return: The time (in seconds) to scroll the alignment.
        :rtype: float
        """
        if filename is None:
            filename = time.strftime("profile%Y%m%d-%H%M%S.prof")
        if print_to_stdout:
            print("writing profiling to", os.path.join(os.getcwd(), filename))
        scroll_time = profiling.profile_call(self.timeScrolling,
                                             only_horizontal=only_horizontal,
                                             print_to_stdout=print_to_stdout,
                                             profile_filename=filename)
        if print_to_stdout:
            print("finished profiling")
        return scroll_time
[docs]    def profileScrollingByPage(self):
        """
        Scroll the alignment while recording profiling data.  Scrolling is only
        done downwards and rightwards so that no cached data is used.
        """
        QtCore.QTimer.singleShot(0, self._profileScrollingByPage)
    def _profileScrollingByPage(self):
        filename = time.strftime("profile_by_page%Y%m%d-%H%M%S.prof")
        print("writing profiling to", os.path.join(os.getcwd(), filename))
        profile.runctx("self.timeScrollingByPage()", globals(), locals(),
                       filename)
        print("finished profiling")
[docs]    @QtCore.pyqtSlot()
    def onWorkspaceColorsChanged(self):
        options = self.options_model
        if (options.colors_enabled and
                isinstance(options.seq_color_scheme, color.WorkspaceScheme)):
            self.pullWorkspaceColors()
[docs]    def pullWorkspaceColors(self):
        """
        Update the current color scheme with colors from the workspace. This
        does not modify the options model.
        """
        color_map = self._structure_model.getWorkspaceColors()
        self._table_model.updateResidueColors(color_map)
[docs]    def applyColorsToWorkspace(self, all_atoms=False):
        """
        Update the colors in the workspace with colors from the MSV.
        :param all_atoms: Whether to color all atoms or just carbons
        :type all_atoms: bool
        """
        color_map = self._table_model.getResidueColors()
        self._structure_model.setWorkspaceColors(color_map, all_atoms=all_atoms)
[docs]    def loadPdbs(self, pdb_paths, index=None):
        """
        Loads a pdb file into the widget.  This will load the PDB into the
        workspace for all widgets.  (See comments on MSV-1326.) If an index is
        specified, sequences will be loaded at that index. Index cannot be
        specified for Workspace tab.
        :type pdb_path: str
        :param pdb_path: Path to the pdb file
        :param index: Index to insert the sequences at. Cannot be specified
                      for Workspace widget.
        :type index: int or None
        :raise: ValueError if index is specified for Workspace widget.
        """
        if self.isWorkspace():
            if index is not None:
                raise ValueError("Cannot load PDBs at specific index for "
                                 "Workspace widget.")
            seqs = self._protectedImport(self._importFilesWorkspace, pdb_paths)
            if seqs:
                self.setSelectedSequences(seqs)
        else:
            seqs = self._protectedImport(self._structure_model.importFiles,
                                         pdb_paths)
            if seqs:
                self.importSeqs(seqs, index=index)
        return seqs
    def _importFilesWorkspace(self, file_paths):
        all_seqs = []
        for path in file_paths:
            seqs = self._structure_model.importStructuresIntoWorkspace(path)
            all_seqs.extend(seqs)
        return all_seqs
    def _protectedImport(self, func, *args, **kwargs):
        """
        Call the specified function with the given arguments to import a
        structure file.  If structure.py raises an exception (indicating that
        the file has an error), display the error to the user and return None.
        Otherwise, return the sequences returned by the function.
        :param func: The import function to call
        :type func: Callable
        :return: A list of imported sequences or None
        :rtype: list(schrodinger.protein.sequence.Sequence) or None
        """
        with dialogs.wait_dialog("Importing sequences, please wait...",
                                 parent=self):
            try:
                return func(*args, **kwargs)
            except Exception as exc:
                self.warning(title="Exception", text=str(exc))
                return None
[docs]    def importIncluded(self, replace_selection=True):
        """
        Import all sequences that are included in the Maestro workspace.
        """
        all_seqs = self._structure_model.getIncludedEntries()
        seqs = self._filterChains(all_seqs)
        if seqs:
            self.importSeqs(seqs, replace_selection=replace_selection)
        self._reportAlreadyLinked(all_seqs, seqs)
[docs]    def importSelected(self):
        """
        Import all sequences that are selected in the Maestro project table.
        """
        all_seqs = self._structure_model.getSelectedEntries()
        seqs = self._filterChains(all_seqs)
        if seqs:
            self.importSeqs(seqs)
        self._reportAlreadyLinked(all_seqs, seqs)
    def _filterChains(self, seqs):
        """
        Return a sequence list that omits any sequences in the provided list that
        are already represented in the alignment.
        :param seqs: An iterable of sequences.
        :type seqs: iterable
        :return: A list of sequences.
        :rtype: list
        """
        already_incl = {
            (seq.entry_id, seq.chain) for seq in self.getAlignment()
        }
        return [
            seq for seq in seqs if (seq.entry_id, seq.chain) not in already_incl
        ]
    def _reportAlreadyLinked(self, all_seqs, imported_seqs):
        """
        If some sequences were not imported, tell the user.
        """
        num_all = len(all_seqs)
        num_skipped = num_all - len(imported_seqs)
        if num_skipped == 0:
            return
        if num_skipped == num_all:
            if num_all == 1:
                # Replace "1 of 1 chain" with "The chain"
                text = "The"
            else:
                # Replace "2 of 2 chains" with "All 2 chains"
                text = f"All {num_all}"
        else:
            text = f"{num_skipped} of {num_all}"
        p = inflect.engine()
        text += p.inflect(
            " requested plural_noun('chain', {1}) "
            "num({0}, False)plural_verb('was') skipped, as plural_noun('it') "
            "plural_verb('was') already linked to ".format(
                num_skipped, num_all))
        text += 'a ' if num_skipped == 1 else ''
        text += p.inflect(f"plural('sequence', {num_skipped}) in this tab")
        self.info(text)
[docs]    def getReferenceSeq(self):
        """
        Helper function that returns the reference sequence.
        :return: reference sequence of the underlying alignment
        :rtype: `Sequence`
        """
        return self.getAlignment().getReferenceSeq()
[docs]    def isWorkspace(self):
        """
        :return: Whether this widget contains an alignment that is always kept
            in sync with the Maestro workspace.
        :rtype: bool
        """
        return self.model.is_workspace
[docs]    def setLightMode(self, enable):
        """
        Enable or disable lightmode on the widget's views and model
        """
        self._light_mode = enable
        self._table_model.setLightMode(enable)
        self.view.setLightMode(enable)
        self.aln_metrics_view.setLightMode(enable)
        self.aln_info_view.setLightMode(enable)
[docs]    def setEditMode(self, enable):
        """
        Enable or disable edit mode.
        :param enable: Whether to enable edit mode.
        :type enable: bool
        """
        self.view.setEditMode(enable)
[docs]    def copySelectedSeqs(self):
        """
        Returns a dictionary of new copies of each selected sequence mapped to
        the sequence it was copied from.
        :return: Dictionary of new sequence copies mapped to their source
                 sequence
        :rtype: dict
        """
        aln = self.getAlignment()
        sel_seqs = aln.getSelectedSequences()
        return {copy.deepcopy(seq): seq for seq in sel_seqs}
[docs]    @QtCore.pyqtSlot()
    def duplicateAtBottom(self):
        """
        Duplicate selected sequences and add at bottom
        """
        seqs_map = self.copySelectedSeqs()
        aln = self.getAlignment()
        if not seqs_map:
            return
        plural_sequences = inflect.engine().no("Sequence", len(seqs_map))
        desc = f"Duplicate {plural_sequences} at Bottom"
        with command.compress_command(self.undo_stack, desc):
            aln.duplicateSeqs(seqs_map)
[docs]    @QtCore.pyqtSlot()
    def duplicateAtTop(self):
        """
        Duplicate selected sequences and add at top
        """
        seqs_map = self.copySelectedSeqs()
        aln = self.getAlignment()
        if not seqs_map:
            return
        plural_sequences = inflect.engine().no("Sequence", len(seqs_map))
        desc = f"Duplicate {plural_sequences} at Top"
        with command.compress_command(self.undo_stack, desc):
            aln.duplicateSeqs(seqs_map, 1)
[docs]    @QtCore.pyqtSlot()
    def duplicateAsRef(self):
        """
        Duplicate selected sequence and make reference seq
        """
        seqs_map = self.copySelectedSeqs()
        new_seqs = list(seqs_map.keys())
        if len(new_seqs) != 1:
            return
        if self._checkCanSetReferenceSeq():
            new_seq = new_seqs[0]
            aln = self.getAlignment()
            desc = f"Duplicate {new_seq.fullname} as Reference Sequence"
            with command.compress_command(self.undo_stack, desc):
                aln.clearAnchors()
                aln.duplicateSeqs(seqs_map, replace_selection=True)
                self.setSelectedSeqAsReference()
[docs]    @QtCore.pyqtSlot()
    def duplicateInPlace(self):
        """
        Duplicate selected sequences and add in place
        """
        seqs_map = self.copySelectedSeqs()
        if not seqs_map:
            return
        aln = self.getAlignment()
        desc = "Duplicate in Place"
        with command.compress_command(self.undo_stack, desc):
            aln.duplicateSeqsHighlightColorMap(seqs_map)
            for new_seq, source_seq in seqs_map.items():
                aln.duplicateSeqs({new_seq: source_seq},
                                  aln.index(source_seq) + 1)
[docs]    @prompt_if_seqs_hidden
    def deleteGapOnlyColumns(self):
        """
        Delete all columns in the alignment comprised of only gaps
        """
        aln = self.getAlignment()
        aln.minimizeAlignment()
    @QtCore.pyqtSlot()
    def _createAlnSet(self):
        """
        Create a new alignment set and add all selected sequences to it.  The
        user will be prompted for the name of the new set.
        """
        set_name = self._promptForAlnSetName(
            previous_set=self._previous_set_name)
        if set_name is None:
            return
        aln = self.getAlignment()
        seqs = aln.seq_selection_model.getSelection()
        aln.addSeqsToAlnSet(seqs, set_name)
        self._previous_set_name = set_name
    @QtCore.pyqtSlot(str)
    def _addToAlnSet(self, set_name):
        """
        Add all selected sequences to the specified alignment set.
        :param set_name: The name of the alignment set to add to
        :type set_name: str
        """
        aln = self.getAlignment()
        seqs = aln.seq_selection_model.getSelection()
        aln.addSeqsToAlnSet(seqs, set_name)
    @QtCore.pyqtSlot()
    def _removeSeqsFromAlnSet(self):
        """
        Remove all selected sequences from their alignment sets.
        """
        aln = self.getAlignment()
        seqs = aln.seq_selection_model.getSelection()
        aln.removeSeqsFromAlnSet(seqs)
    @QtCore.pyqtSlot(str)
    def _selectAlnSet(self, set_name):
        """
        Select all sequences in the named alignment set.
        """
        self._selectAlnSetImplementation(set_name, True)
    @QtCore.pyqtSlot(str)
    def _deselectAlnSet(self, set_name):
        """
        Deselect all sequences in the named alignment set.
        """
        self._selectAlnSetImplementation(set_name, False)
    def _selectAlnSetImplementation(self, set_name, select):
        """
        Select-only or deselect the sequence in the given set.
        :param set_name: name of the sequence set.
        :type set_name: str
        :param select: Whether to select only or deselect.
        :type select: bool
        """
        aln = self.getAlignment()
        aln_set = aln.getAlnSet(set_name)
        if select:
            aln.seq_selection_model.clearSelection()
        aln.seq_selection_model.setSelectionState(aln_set, select)
    @QtCore.pyqtSlot(str)
    def _dissolveAlnSet(self, set_name):
        """
        Dissolve the named alignment set.
        """
        aln = self.getAlignment()
        aln_set = aln.getAlnSet(set_name)
        aln.removeSeqsFromAlnSet(aln_set)
    @QtCore.pyqtSlot()
    def _gatherAlnSets(self):
        self.getAlignment().gatherAlnSets()
    @QtCore.pyqtSlot(str)
    def _renameAlnSet(self, set_name):
        """
        Rename the named alignment set. The user will be prompted for the new
        name.
        """
        new_name = self._promptForAlnSetName(current_name=set_name)
        if new_name is None:
            return
        aln = self.getAlignment()
        aln.renameAlnSet(set_name, new_name)
    def _promptForAlnSetName(self, current_name=None, previous_set=None):
        """
        Prompt the user for an alignment set name.
        :param current_name: Current name for a rename action
        :type current_name: str or NoneType
        :return: Name if it's non-empty and unique, otherwise None
        :rtype: str or NoneType
        """
        if current_name is None:
            title = "Alignment set"
            label = "Enter set name"
            name = 'Set 1'
            if previous_set is not None:
                name = _getNextSetName(previous_set)
            # Check if the set with suggested name already exists.
            existing_sets = self.getAlignment().alnSetNames()
            while name in existing_sets:
                name = _getNextSetName(name)
            kwargs = {'text': name}
        else:
            title = "Rename alignment set"
            label = "Enter new set name"
            kwargs = {'text': current_name}
        set_name, ok_clicked = QtWidgets.QInputDialog.getText(
            self, title, label, **kwargs)
        aln = self.getAlignment()
        if not ok_clicked:
            return
        elif not set_name.strip():
            self.error("No name given.")
            return
        elif set_name in aln.alnSetNames():
            self.error(f'Set "{set_name}" already exists.')
            return
        return set_name
[docs]    def showWorkspaceSequences(self):
        aln = self.getAlignment()
        Incl = viewconstants.Inclusion
        vis_states = (Incl.PartiallyVisible, Incl.FullyVisible)
        ws_seqs = [seq for seq in aln if seq.visibility in vis_states]
        if ws_seqs:
            with command.compress_command(self.undo_stack,
                                          "Show Workspace Sequences Only"):
                aln.showSeqs(ws_seqs, hide_others=True)
[docs]    def showAllSeqs(self):
        self.getAlignment().showAllSeqs()
        self.model.options.seq_filter_enabled = False
[docs]    @QtCore.pyqtSlot()
    def enableFindSequence(self):
        """
        Enable sequence filtering on the model
        """
        self.model.options.seq_filter_enabled = True
    @QtCore.pyqtSlot()
    def _clearSeqFilter(self):
        self.model.options.seq_filter = ""
[docs]class ProteinAlignmentMsvWidget(AbstractMsvWidget):
    """
    :ivar startBlastTaskRequested: Signal emitted to request running a blast
        task. The widget doesn't start the task itself to avoid problems if the
        widget is deleted while the task is running.
    :ivar startPredictorBlastTaskRequested: Signal emitted to request running a
        blast task for predictors.
    :ivar proteinStructAlignResultsReady: Signal emitted when protein structure
        alignment results are ready. Emitted with a list of
        `align.AbstractStructureAligner.Result` namedtuples.
    """
    alignmentFinished = QtCore.pyqtSignal()
    startBlastTaskRequested = QtCore.pyqtSignal(blast.BlastTask)
    startPredictorBlastTaskRequested = QtCore.pyqtSignal(blast.BlastTask)
    proteinStructAlignResultsReady = QtCore.pyqtSignal(list)
[docs]    def initSetUp(self):
        super().initSetUp()
        self._descriptors_cleared_timer = QtCore.QTimer()
        self._descriptors_cleared_timer.setSingleShot(True)
        self._descriptors_cleared_timer.timeout.connect(
            self._showDescriptorsClearedWarning)
        self._pfam_task_queue = None
        self._kinase_task_queue = None
        self._kinase_cons_task_queue = None
        self._align_method_map = {
            viewconstants.SeqAlnMode.Multiple: self.multipleAlignment,
            viewconstants.SeqAlnMode.PairwiseSS:
                self.pairwiseAlignmentSecondaryStructure,
            viewconstants.SeqAlnMode.Pairwise: self.invokePairwiseAlignment,
            viewconstants.SeqAlnMode.Structure: self.alignBySuperposition,
            viewconstants.SeqAlnMode.Residue: self.alignByResidueNumbers,
            viewconstants.SeqAlnMode.Profile: self.invokeProfileAlignment,
            viewconstants.StructAlnMode.Superimpose: self.superimposeStructures,
            viewconstants.StructAlnMode.BindingSite:
                self.runBindingSiteAlignment,
            viewconstants.StructAlnMode.Structure: self.runStructureAlignment,
        }
[docs]    def initSetOptions(self):
        super().initSetOptions()
        self.blast_search_dialog = self._initBlastSearchDialog()
[docs]    def defineMappings(self):
        mappings = super().defineMappings()
        M = self.model_class
        mappings.extend([
            (self.blast_search_dialog, M.blast_task.input),
            (self.blast_search_dialog.settings_dlg.ui.local_only_cb, M.options.blast_local_only),
        ])  # yapf: disable
        return mappings
[docs]    def getSignalsAndSlots(self, model):
        ss = super().getSignalsAndSlots(model)
        hm_input = model.homology_modeling_input
        hm_settings = hm_input.settings
        ss.extend([
            (hm_input.pick_chimeraChanged, self._onHMPickChimeraChanged),
            (hm_settings.ligand_dlg_model.pick_ligandChanged, self._onHMPickLigandChanged),
            (hm_settings.pick_proximityChanged, self._onHMPickProximityChanged),
            (model.options.binding_site_distanceChanged, self._onBindingSiteDistanceChanged),
            (model.options.seq_filter_enabledChanged, self._onSeqFilterEnabledChanged),
            (model.options.seq_filterChanged, self._onSeqFilterChanged),
            (model.options.pick_modeChanged, self._onPickModeChanged),
            (model.options.kinase_features_enabledChanged, self._generateKinaseFeatures),
            (model.options.sequence_annotations.mutated, self._generateAnnotations),
            (model.aln_signals.descriptorsCleared, self._descriptors_cleared_timer.start),
        ])  # yapf: disable
        return ss
    @QtCore.pyqtSlot()
    def _showDescriptorsClearedWarning(self):
        self.warning(title="Descriptors Invalidated",
                     text=_DESCRIPTORS_CLEARED_WARNING_MSG)
    @QtCore.pyqtSlot(object)
    def _onHMPickLigandChanged(self, pick):
        pick_mode = PickMode.HMBindingSite if pick else None
        self.model.options.pick_mode = pick_mode
    @QtCore.pyqtSlot(object)
    def _onHMPickChimeraChanged(self, pick):
        pick_mode = PickMode.HMChimera if pick else None
        self.model.options.pick_mode = pick_mode
    @QtCore.pyqtSlot(object)
    def _onHMPickProximityChanged(self, pick):
        pick_mode = PickMode.HMProximity if pick else None
        self.model.options.pick_mode = pick_mode
    @QtCore.pyqtSlot()
    def _onBindingSiteDistanceChanged(self):
        """
        Update the annotation binding site distance
        """
        distance = self.model.options.binding_site_distance.value
        for seq in self.model.aln:
            seq.annotations.setLigandDistance(distance)
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(bool)
    def _onSeqFilterEnabledChanged(self, enabled):
        self.model.split_aln.setSeqFilterEnabled(enabled)
    @QtCore.pyqtSlot(object)
    @QtCore.pyqtSlot(str)
    def _onSeqFilterChanged(self, filter):
        self.model.split_aln.setSeqFilterQuery(filter)
    @QtCore.pyqtSlot(object)
    def _onPickModeChanged(self, pick_mode):
        do_chimera = pick_mode is PickMode.HMChimera
        do_ligand = pick_mode is PickMode.HMBindingSite
        do_proximity = pick_mode is PickMode.HMProximity
        hm_input = self.model.homology_modeling_input
        hm_settings = hm_input.settings
        hm_input.pick_chimera = do_chimera
        hm_settings.ligand_dlg_model.pick_ligand = do_ligand
        hm_settings.pick_proximity = do_proximity
    def _getNewAlignment(self):
        """
        Return a ProteinAlignment with a reference to the widget's undo stack
        set on it.
        :return: `schrodinger.application.msv.alignment.Alignment`
        """
        aln = gui_alignment.GuiProteinAlignment()
        return aln
    def _getAlignmentView(self, undo_stack):
        """
        Return an alignment view appropriate for the widget.
        """
        return view.ProteinAlignmentView(undo_stack, self)
    def _getNoScrollAlignmentView(self):
        """
        Return an alignment view without scroll bars, that is not shown
        """
        return view.NoScrollAlignmentView(None)
    def _getAlignmentInfoView(self):
        """
        Return an alignment view for the alignment info columns on the right
        side
        """
        return view.AlignmentInfoView(self.view, self)
    def _getAlignmentMetricsView(self):
        """
        Return an alignment view for the "frozen" metrics columns on the right
        side
        """
        return view.AlignmentMetricsView(self.view, self)
    def _getAlignmentViewModel(self):
        """
        Return an alignment viewmodel appropriate for the widget.
        """
        return viewmodel.ViewModel(self)
    @QtCore.pyqtSlot(set, set)
    def _generateAnnotations(self, new_annos, old_annos):
        """
        Start calculation for newly-enabled sequence annotations.
        Note::
            This is for any annotations that require a long calculation.
        :param new_annos: Annotations that are currently enabled
        :type new_annos: set
        :param old_annos: Annotations that were already enabled
        :type new_annos: set
        """
        added = new_annos - old_annos
        if added:
            if SEQ_ANNO_TYPES.kinase_conservation in added:
                self._generateKinaseConservation()
    def _generateKinaseConservation(self):
        """
        Calculate kinase conservation if it's enabled in the options model
        """
        if SEQ_ANNO_TYPES.kinase_conservation not in self.model.options.sequence_annotations:
            return
        num_tasks = 0
        for seq in self.getShownSplitSequences():
            # TODO MSV-3404 handle invalidating kinase features on edit
            if (not seq.hasStructure() or seq.is_kinase_cons_annotated or
                (seq.is_kinase_annotated and not seq.isKinaseChain())):
                continue
            if self._kinase_cons_task_queue is None:
                self._kinase_cons_task_queue = MsvTaskQueue(
                    description="Computing kinase conservation...",
                    max_running_tasks=1)
                self._kinase_cons_task_queue.queueDone.connect(
                    self._onKinaseConsTaskQueueDone)
            for lig_asl in seq.annotations.ligand_asls:
                task = kinase.KinaseFeatureAndConservationTask()
                task.input.seq = seq
                task.input.ligand_asl = lig_asl
                self._kinase_cons_task_queue.addTask(task)
                num_tasks += 1
        if num_tasks:
            if self._kinase_cons_task_queue.status != task.RUNNING:
                self._kinase_cons_task_queue.start()
            self.taskStarted.emit(self._kinase_cons_task_queue)
    def _onKinaseConsTaskQueueDone(self):
        for task in self._kinase_cons_task_queue.getTasks():
            seq = task.input.seq
            # TODO MSV-3697 report errors
            if task.status is task.DONE:
                if not seq.is_kinase_annotated:
                    seq.setKinaseFeatures(task.getKinaseFeatures())
                if seq.isKinaseChain():
                    lig_asl = task.input.ligand_asl
                    results = task.parseConservationOutput()
                    seq.setKinaseConservation(results, lig_asl)
                task._tempdir.cleanup()
        self._kinase_cons_task_queue = None
    def _generateKinaseFeatures(self):
        """
        Calculate kinase features if it's enabled in the options model
        """
        if not self.model.options.kinase_features_enabled:
            return
        num_tasks = 0
        for seq in self.getShownSplitSequences():
            # TODO MSV-3404 handle invalidating kinase features on edit
            if seq.is_kinase_annotated:
                continue
            if self._kinase_task_queue is None:
                self._kinase_task_queue = MsvTaskQueue(
                    description="Computing kinase features...",
                    max_running_tasks=1)
                self._kinase_task_queue.queueDone.connect(
                    self._onKinaseTaskQueueDone)
            task = kinase.KinaseFeatureTask()
            task.input.seq = seq
            self._kinase_task_queue.addTask(task)
            num_tasks += 1
        if num_tasks:
            if self._kinase_task_queue.status != task.RUNNING:
                self._kinase_task_queue.start()
            self.taskStarted.emit(self._kinase_task_queue)
    @QtCore.pyqtSlot()
    def _onKinaseTaskQueueDone(self):
        for task in self._kinase_task_queue.getTasks():
            # TODO MSV-3697 report errors
            if task.status is task.DONE:
                features = task.getKinaseFeatures()
                task.input.seq.setKinaseFeatures(features)
            task._tempdir.cleanup()
        self._kinase_task_queue = None
[docs]    def onQuickAlignRequested(self):
        """
        Align based on model settings
        """
        settings = self.model.options.align_settings
        align_mode = settings.getAlignMode()
        self.callAlignMethod(align_mode)
[docs]    def callAlignMethod(self, align_mode: Union[viewconstants.StructAlnMode,
                                                viewconstants.StructAlnMode]):
        """
        Call appropriate Align method based the alignment mode.
        """
        align_method = self._align_method_map[align_mode]
        success = align_method()
        if success:
            self.alignmentFinished.emit()
[docs]    def invokeProfileAlignment(self):
        aln = self.getAlignment()
        if aln.getAnchoredResidues():
            title = "Anchors Found"
            text = ("Profile alignments are performed on the entire sequence. "
                    "All anchors will be removed. Continue with the alignment?")
            if not self.question(title=title, text=text):
                return
        if aln.res_selection_model.hasSelection():
            title = "Residues Selected"
            text = ("Profile alignments are performed on the entire sequence. "
                    "Residue selection will be cleared. Continue with the "
                    "alignment?")
            if not self.question(title=title, text=text):
                return
        return self.runProfileAlignment()
[docs]    @validate_align.align_command(undo_desc="Profile Alignment",
                                  can_align_sets=True)
    def runProfileAlignment(self):
        full_aln = self.getAlignment()
        full_aln.gatherAlnSets()
        aln_sets, loose_seqs = validate_align.get_aln_sets_and_seqs_to_align(
            full_aln)
        aln_mode = validate_align.get_aln_set_align_mode(full_aln,
                                                         aln_sets=aln_sets,
                                                         loose_seqs=loose_seqs)
        if aln_mode is not viewconstants.SeqAlnMode.Profile:
            raise align.CantAlignException(
                "Can only run profile-profile alignment with 2 alignment sets "
                "or 1 alignment set and loose sequences")
        assert len(aln_sets) > 0
        first_set = aln_sets[0]
        assert full_aln.getReferenceSeq() in first_set
        seq_idxs1 = [full_aln.index(seq) for seq in first_set]
        if len(aln_sets) == 2:
            assert not loose_seqs
            seq_idxs2 = [full_aln.index(seq) for seq in aln_sets[1]]
            self._profileAlignment(full_aln, seq_idxs1, seq_idxs2)
        else:
            assert len(aln_sets) == 1
            prev_seq_index = None
            for seq in loose_seqs:
                seq_index = full_aln.index(seq)
                # Virtually add previous loose seq to existing profile
                if prev_seq_index is not None:
                    seq_idxs1.append(prev_seq_index)
                # Align single loose seq to existing profile
                self._profileAlignment(full_aln, seq_idxs1, [seq_index])
                prev_seq_index = seq_index
    def _profileAlignment(self, full_aln, indices1, indices2):
        """
        :param full_aln: The full alignment to align
        :param indices1: Sequence indices to use as the first alignment. Must
            start at 0 and be adjacent.
        :param indices2: Sequence indices to use as the second alignment. Must
            start immediately after indices1 and be adjacent.
        """
        prev_idx1 = None
        indices1 = sorted(indices1)
        if indices1[0] != 0:
            raise ValueError("First profile must start with the first sequence")
        for idx in indices1:
            if prev_idx1 is not None and idx - prev_idx1 != 1:
                raise ValueError("Profile indices must be adjacent")
            prev_idx1 = idx
        indices2 = sorted(indices2)
        if indices2[0] != prev_idx1 + 1:
            raise ValueError("Aln1 and Aln2 profile indices must be adjacent")
        prev_idx2 = None
        for idx in indices2:
            if prev_idx2 is not None and idx - prev_idx2 != 1:
                raise ValueError("Profile indices must be adjacent")
            prev_idx2 = idx
        AlnClass = type(full_aln)
        seqs1 = []
        seqs2 = []
        missing_indices = []
        for idx, seq in enumerate(full_aln):
            if idx in indices1:
                seqs1.append(copy.deepcopy(seq))
            elif idx in indices2:
                seqs2.append(copy.deepcopy(seq))
            else:
                missing_indices.append(idx)
        if missing_indices:
            msg = "Missing indices must start immediately after indices2"
            assert missing_indices[0] == prev_idx2 + 1, msg
        assert len(seqs1) + len(seqs2) + len(missing_indices) == len(full_aln)
        aln1 = AlnClass(seqs1)
        aln2 = AlnClass(seqs2)
        job = muscle.MuscleJob(aln1, aln2)
        new_gap_indices = self._runMultipleAlignmentJob(job)
        if new_gap_indices is not None:
            full_aln.removeAllGaps()
            if missing_indices:
                new_gap_indices.extend([] for _ in missing_indices)
            full_aln.addGapsByIndices(new_gap_indices)
[docs]    @validate_align.align_command(undo_desc="Multiple Alignment",
                                  split_by_anchors=True,
                                  can_align_sets=True,
                                  superimpose_param=gui_models.
                                  AlignSettingsModel.multiple.superimpose_after)
    def multipleAlignment(self, *, _start=None, _end=None):
        """
        Run multiple sequence alignment.
        `_start` and `_end` are private implementation params and not part of
        the public API (will be ignored if passed)
        """
        self._callAlignFunc(start=_start,
                            end=_end,
                            aln_func=self._multipleAlignment)
        align_settings = self.model.options.align_settings.multiple
        if align_settings.find_globally_conserved:
            self.model.options.color_by_aln = viewconstants.ColorByAln.Matching
            self.generatePfam()
    def _multipleAlignment(self, aln):
        align_settings = self.model.options.align_settings.multiple
        algo = align_settings.aln_algorithm
        if algo is viewconstants.MultAlnAlgorithm.Clustal:
            JobClass = clustal.ClustalJob
        else:
            JobClass = muscle.MuscleJob
        job = JobClass(aln)
        gapopen = align_settings.gap_open_penalty
        gapext = align_settings.gap_extend_penalty
        job.setGapPenalties(gapopen, gapext)
        new_gap_indices = self._runMultipleAlignmentJob(job)
        if new_gap_indices is not None:
            aln.removeAllGaps()
            aln.addGapsByIndices(new_gap_indices)
    def _runMultipleAlignmentJob(self, job):
        # Open a progress bar using number of output lines from clustal as a
        # measure of progress.
        # Estimate the total number of lines using the number of possible
        # pairwise alignments plus some.
        total = job.maximum_progress * 1.25  # Add extra to account for post-
        # processing time to add gaps
        with dialogs.aln_progress_dialog(job,
                                         text='Aligning...',
                                         total=total,
                                         parent=self):
            new_aln = job.run()
            if not new_aln:
                return
            new_gaps = new_aln.getGaps()
            new_gap_indices = [
                [g.idx_in_seq for g in seq_gaps] for seq_gaps in new_gaps
            ]
            return new_gap_indices
[docs]    def optimizeAlignment(self,
                          target_seq,
                          template_seq,
                          ligand_asl=None,
                          residue_asl=None,
                          radius=None):
        if not template_seq.hasStructure():
            self.warning(
                "Optimize alignment requires template sequence to have structure."
            )
            return
        task = optimize_alignment.OptimizeAlignmentTask()
        task.input.target_seq = target_seq
        task.input.template_seq = template_seq
        task.input.ligand_asl = ligand_asl
        task.input.residue_asl = residue_asl
        if radius is not None:
            task.input.radius = radius
        task.statusChanged.connect(self._onOptimizeAlignmentTaskStatusChanged)
        self.taskStarted.emit(task)
        task.start()
    @QtCore.pyqtSlot()
    def _onOptimizeAlignmentTaskStatusChanged(self):
        task = self.sender()
        if task.status is task.FAILED:
            self.warning("Optimize alignment failed.")
        elif task.status is task.DONE:
            aln = self.getAlignment()
            gaps = []
            for seq in aln:
                if seq == task.input.target_seq:
                    gap_seq = task.output.target_seq
                elif seq == task.input.template_seq:
                    gap_seq = task.output.template_seq
                else:
                    gap_seq = seq
                gaps.append([g.idx_in_seq for g in gap_seq.getGaps()])
            with command.compress_command(self.undo_stack,
                                          "Optimize alignment"):
                aln.removeAllGaps()
                aln.addGapsByIndices(gaps)
    def _callAlignFunc(self, aln_func, start=None, end=None):
        """
        Helper method to align a subalignment
        :param aln_func: Align method
        :type  aln_func: callable
        :param start: Start index of the subalignment
        :type  start: int or None
        :param end: Start index of the subalignment (exclusive)
        :type  end: int or None
        """
        full_aln = self.getAlignment()
        if (start is None) != (end is None):
            msg = "Start and end must be both None or both int"
            raise align.CantAlignException(msg)
        if start is not None and end is not None:
            full_aln.padAlignment()
            aln = full_aln.getSubalignment(start, end)
            orig_gap_indices = [
                [g.idx_in_seq for g in seq.getGaps()] for seq in aln
            ]
            aln_func(aln)
            new_gap_indices = [
                [g.idx_in_seq for g in seq.getGaps()] for seq in aln
            ]
            if new_gap_indices != orig_gap_indices:
                gaps_to_remove = []
                for seq, gap_indices in zip(full_aln, orig_gap_indices):
                    gaps_to_remove.extend(
                        seq[g_idx + start] for g_idx in gap_indices)
                gap_idxs_to_add = [[idx + start
                                    for idx in s_idxs]
                                   for s_idxs in new_gap_indices]
                with full_aln.suspendAnchors():
                    full_aln.removeElements(gaps_to_remove)
                    # TODO MSV-1771 new gaps should be selected
                    full_aln.addGapsByIndices(gap_idxs_to_add)
            full_aln.removeTerminalGaps()
        else:
            aln_func(full_aln)
[docs]    def updateWorkspaceSelection(self):
        """
        Apply residue selection from this widget to the Maestro workspace.  This
        will remove residue selection for any workspace entries without a linked
        sequence and will update residue selection for any entries awaiting
        delayed sync.  (Residue selection is not automatically synchronized for
        newly included entries until selection is changed in either the
        workspace or the MSV.  This method will force that synchronization.)
        """
        aln = self.getAlignment()
        self._structure_model.delayedSyncFromMsvToWorkspace(aln)
[docs]    def invokePairwiseAlignment(self):
        settings = self.model.options.align_settings.pairwise
        if settings.lock_gaps:
            success = self.mergedPairwiseAlignment()
        else:
            success = self.pairwiseAlignment()
        return success
[docs]    @validate_align.align_command(undo_desc="Pairwise Sequence Alignment",
                                  pairwise=True,
                                  superimpose_param=gui_models.
                                  AlignSettingsModel.pairwise.superimpose_after)
    def pairwiseAlignment(self, *, _start=None, _end=None):
        """
        Run pairwise sequence alignment using modified Needleman-Wunsch algorithm.
        If residues are anchored, pairwise alignment with locked reference gaps
        will be performed instead.
        If no sequences are selected, the first non-reference sequence will be
        aligned to the reference sequence.
        If one or more sequences are selected, each selected non-reference
        sequence will be aligned to the reference sequence.
        `_start` and `_end` are private implementation params and not part of
        the public API (will be ignored if passed)
        """
        merge = False
        if len(self.getAlignment().getAnchoredResidues()):
            text = "These sequences contain anchored residues. The alignment " \
                    "will be run with all Reference gaps locked.\nContinue anyway?"
            proceed = self.question(text,
                                    title="Anchored Residues Found",
                                    save_response_key="pairwise_anchors")
            if proceed:
                merge = True
            else:
                return
        aln_func = partial(self._pairwiseAlignment, merge=merge)
        self._callAlignFunc(start=_start, end=_end, aln_func=aln_func)
    def _pairwiseAlignment(self, aln, merge=False):
        align_settings = self.model.options.align_settings.pairwise
        if isinstance(aln.getReferenceSeq(), sequence.NucleicAcidSequence):
            # The substitution matrixes are protein-specific
            sub_matrix = None
        else:
            sub_matrix = getattr(constants, align_settings.sub_matrix, None)
        if align_settings.prevent_ss_gaps:
            ALIGNER_CLASS = align.SchrodingerPairwiseAligner
        else:
            ALIGNER_CLASS = align.BiopythonPairwiseAligner
        aligner = ALIGNER_CLASS(
            gap_open_penalty=align_settings.gap_open_penalty,
            gap_extend_penalty=align_settings.gap_extend_penalty,
            sub_matrix=sub_matrix,
            preserve_reference_gaps=merge,
            ss_constraints=align_settings.prevent_ss_gaps,
            penalize_end_gaps=align_settings.penalize_end_gaps)
        if not merge:
            ref_gaps = aln.getReferenceSeq().getGaps()
            aln.removeElements(ref_gaps)
        # Get seqs_to_align using full_aln because aln may not have selection
        full_aln = self.getAlignment()
        ref_seq = full_aln.getReferenceSeq()
        seq_idxs_to_align = (full_aln.index(seq)
                             for seq in full_aln.getSelectedSequences()
                             if seq != ref_seq)
        seqs_to_align = [aln[s_idx] for s_idx in seq_idxs_to_align]
        if (align_settings.set_constraints and
                full_aln.pairwise_constraints.hasConstraints()):
            # `aln` may not have pairwise constraints so we need to convert
            # `full_aln` pairwise constraints to use residue objects from `aln`
            ref_res_map = {
                res.rescode: res for res in aln.getReferenceSeq() if res.is_res
            }
            other_res_map = {res.rescode: res for res in aln[1] if res.is_res}
            constraints = []
            for ref_res, other_res in full_aln.pairwise_constraints.getPairs():
                new_ref = ref_res_map.get(ref_res.rescode)
                new_other = other_res_map.get(other_res.rescode)
                if new_ref is not None and new_other is not None:
                    constraints.append((new_ref, new_other))
        else:
            constraints = None
        aligner.run(aln, seqs_to_align, constraints=constraints)
[docs]    @validate_align.align_command(
        undo_desc="Pairwise Sequence Alignment with Locked Reference Gaps",
        pairwise=True)
    def mergedPairwiseAlignment(self, *, _start=None, _end=None):
        """
        Runs a pairwise alignment while preserving reference gaps.
        `_start` and `_end` are private implementation params and not part of
        the public API (will be ignored if passed)
        """
        aln_func = partial(self._pairwiseAlignment, merge=True)
        self._callAlignFunc(start=_start, end=_end, aln_func=aln_func)
[docs]    @qt_utils.wait_cursor
    @validate_align.align_command(
        undo_desc=
        "Pairwise Sequence Alignment with Secondary Structure Prediction",
        pairwise=True)
    def pairwiseAlignmentSecondaryStructure(self):
        settings = self.model.options.align_settings.pairwise_ss
        protein_family = "GPCR" if settings.use_gpcr_aln else None
        aligner = align.PrimeSTAAligner(protein_family=protein_family)
        aln = self.getAlignment()
        if settings.set_constraints:
            constraints = aln.pairwise_constraints.getPairs()
        else:
            constraints = None
        aligner.run(aln, constraints=constraints)
[docs]    @validate_align.align_command(undo_desc="Superposition Alignment",
                                  structure=True)
    def alignBySuperposition(self):
        """
        Align sequences by structure superposition.
        The pairwise residue C-alpha distances are converted to a scoring matrix
        where a short distance is a high score and vice versa.
        """
        align_settings = self.model.options.align_settings.pairwise
        aligner = align.SuperpositionAligner(
            gap_open_penalty=align_settings.gap_open_penalty,
            gap_extend_penalty=align_settings.gap_extend_penalty)
        aln = self.getAlignment()
        aligner.run(aln, seqs_to_align=aln[1:])
        aln.minimizeAlignment()
[docs]    @validate_align.align_command(
        undo_desc="Align by Residue Number",
        split_by_anchors=True,
        superimpose_param=gui_models.AlignSettingsModel.residue_number.
        superimpose_after)
    def alignByResidueNumbers(self, *, _start=None, _end=None):
        """
        Align by residue numbers.
        `_start` and `_end` are private implementation params and not part of
        the public API (will be ignored if passed)
        """
        self._callAlignFunc(start=_start,
                            end=_end,
                            aln_func=self._alignByResidueNumbers)
    def _alignByResidueNumbers(self, aln):
        aligner = align.RescodeAligner()
        aligner.run(aln)
        aln.minimizeAlignment()
        aln.padAlignment()
[docs]    def superimposeStructures(self, show_panel=True):
        """
        Superimposes the workspace structures according to their sequence
        alignment in the MSV.
        :param show_panel: Whether to show the superimpose ASL panel
        :type  show_panel: bool
        """
        try:
            selected_seqs = self._getSeqsForStructureAlignment(
                require_ref=False)
        except align.CantAlignException as exc:
            self.warning(title="Cannot Superimpose Structures", text=str(exc))
            return
        if selected_seqs is None:
            return
        # Split entries if needed
        selected_seqs = self._splitAllChains(selected_seqs, prompt=True)
        if selected_seqs is None:
            return
        if show_panel:
            maestro.command("showpanel superimpose")
        align_settings = self.model.options.align_settings.superimpose
        selected_only = align_settings.align_sel_res_only
        aln = self.getAlignment()
        entry_residue_map = validate_align.get_residue_map_to_superimpose(
            aln, selected_seqs, selected_only=selected_only)
        if self.model.is_workspace:
            orig_seqs = list(aln)
        self._structure_model.superimposeByAlignment(entry_residue_map)
        if self.model.is_workspace:
            # Superimposing requires excluding entries that aren't selected and
            # reincluding them will move their linked sequences to the end. So
            # we manually reorder to keep the original sequence order.
            new_index_map = {seq: idx for idx, seq in enumerate(aln)}
            reorder_indexes = [new_index_map[seq] for seq in orig_seqs]
            if reorder_indexes != list(range(len(aln))):
                aln.reorderSequences(reorder_indexes)
        return True
[docs]    def runStructureAlignment(self):
        """
        Runs protein structure alignment.
        """
        aln = self.getAlignment()
        ref_seq = aln.getReferenceSeq()
        settings = copy.deepcopy(
            self.model.options.align_settings.protein_structure)
        Represents = viewconstants.StructAlnSequenceRepresents
        Transform = viewconstants.StructAlnTransform
        # Expand selection to entire entries if necessary
        if (self.model.split_chain_view and
                settings.seq_represents is Represents.EntireEntry):
            sel_model = aln.seq_selection_model
            eids = {seq.entry_id for seq in sel_model.getSelection()}
            eids.discard(None)
            entry_seqs = (seq for seq in aln if seq.entry_id in eids)
            sel_model.setSelectionState(entry_seqs, True)
        try:
            selected_seqs = self._getSeqsForStructureAlignment()
        except align.CantAlignException as exc:
            self.warning(title="Cannot Align Structures", text=str(exc))
            return
        if selected_seqs is None:
            return
        if (settings.seq_represents is Represents.SingleChain and
                settings.align_transforms is Transform.Existing):
            eids = [int(seq.entry_id) for seq in selected_seqs]
            eid_counts = Counter(eids)
            ref_eid_count = eid_counts.pop(int(ref_seq.entry_id))
            any_nonref_eids = bool(eid_counts)
            if ref_eid_count > 1 and not any_nonref_eids:
                dlg = dialogs.StructAlignExistingEntryMessageBox(parent=self)
                response = dlg.exec()
                if response:
                    settings.align_transforms = Transform.Individual
                else:
                    return
            elif ref_eid_count > 1 and any_nonref_eids and settings.align_seqs:
                dlg = dialogs.StructAlignMultipleRefChainMessageBox(parent=self)
                response = dlg.exec()
                if response:
                    settings.align_seqs = False
                else:
                    return
        should_split = (settings.seq_represents is Represents.SingleChain and
                        settings.align_transforms is Transform.Individual)
        if should_split:
            selected_seqs = self._splitAllChains(selected_seqs, prompt=False)
        selected_seqs.remove(ref_seq)
        return self._runStructureAlignment(settings, selected_seqs)
    @validate_align.align_command(undo_desc="Structure Alignment",
                                  structure=True,
                                  split_res_blocks=False)
    def _runStructureAlignment(self, settings, selected_seqs):
        aln = self.getAlignment()
        ref_seq = aln.getReferenceSeq()
        keywords = dict()
        if settings.force:
            # Based on mmshare/apps/ska/scripts/structalign_utility.py
            keywords['RECKLESS'] = 'yes'
        Represents = viewconstants.StructAlnSequenceRepresents
        if settings.seq_represents is Represents.SingleChain:
            if settings.map_seqs:
                results = []
                for each_map in settings.chain_name:
                    ref_chain = each_map.reference
                    seq_chain = each_map.seq_chain[1]
                    seq_entry_id = each_map.seq_chain[2]
                    ref_asl = f"entry.id {ref_seq.entry_id} and chain.name {ref_chain}"
                    other_asl = f"entry.id {seq_entry_id} and chain.name {seq_chain}"
                    aligner = align.CustomASLStructureAligner(
                        keywords=keywords, ref_asl=ref_asl, other_asl=other_asl)
                    aligner.run(aln, seqs_to_align=selected_seqs)
                    _result = aligner.getResultSeqs()
                    if _result:
                        results.append(_result[0])
                self._showAlignmentResultDialog(results)
                return
            aligner = align.StructureAligner(keywords=keywords)
        else:
            eids = {seq.entry_id for seq in selected_seqs}
            eids.add(ref_seq.entry_id)
            eids.discard(None)
            if len(eids) == 1:
                raise align.CantAlignException(
                    "Cannot run Protein Structure Alignment on entire entries "
                    "with only 1 entry selected")
            RefASLMode = viewconstants.StructAlnRefASLMode
            other_asl = None
            # Get ASL for reference
            if settings.ref_asl_mode is RefASLMode.All:
                ref_asl = settings.ref_asl
            elif settings.ref_asl_mode is RefASLMode.Selected:
                ref_asl, other_asl = self._createSelectedResidueASLs(
                    aln, settings.other_define_asl)
            elif settings.ref_asl_mode is RefASLMode.ASL:
                # TODO MSV-2790 May need to expand ASL to residues
                ref_asl = settings.ref_asl
            else:
                assert False
            # Get ASL for non-reference
            if settings.other_define_asl:
                # TODO MSV-2790 May need to expand ASL to residues
                other_asl = settings.other_asl
            elif other_asl is None:
                other_asl = ref_asl
            aligner = align.CustomASLStructureAligner(keywords=keywords,
                                                      ref_asl=ref_asl,
                                                      other_asl=other_asl)
            # Check that ASLs are valid
            result = aligner.evaluateASLs(aln, seqs_to_align=selected_seqs)
            if not result.ref_ok:
                self.error(f"No reference atoms matched the ASL {ref_asl}")
                return
            if not result.other_ok:
                self.error(
                    f"No non-reference atoms matched the ASL {other_asl}")
                return
            if result.other_ok and result.other_skips:
                msg = ("The following non-reference sequence(s) have no atoms "
                       "matching the ASL and will not have their structures "
                       "aligned: ")
                msg += ", ".join(seq.name for seq in result.other_skips)
                self.warning(msg)
        aligner.run(aln, seqs_to_align=selected_seqs)
        if settings.align_seqs:
            self.alignBySuperposition()
        results = aligner.getResultSeqs()
        self._showAlignmentResultDialog(results)
    def _showAlignmentResultDialog(self, results):
        if not results:
            self.warning("No Protein Structure Alignments were produced.")
            return
        self.proteinStructAlignResultsReady.emit(results)
        dlg = dialogs.StructAlignResultDialog(parent=self)
        dlg.setResults(results)
        dlg.run(modal=True)
[docs]    @validate_align.align_command(undo_desc="Binding Site Alignment",
                                  structure=True,
                                  pairwise=True,
                                  split_res_blocks=False)
    def runBindingSiteAlignment(self):
        """
        Runs binding site alignment
        """
        selected_seqs = self._getSeqsForBindingSiteAlignment()
        if selected_seqs is None:
            return
        self.model.options.sequence_annotations.add(
            SEQ_ANNO_TYPES.binding_sites)
        aln = self.getAlignment()
        ref_seq = aln.getReferenceSeq()
        settings = self.model.options.align_settings.binding_site
        selected_res = []
        if settings.align_sel_res_only:
            all_selected_res = aln.res_selection_model.getSelection()
            selected_res = [
                res for res in ref_seq.residues() if res in all_selected_res
            ]
        task = self._getBindingSiteAlignTask(seqs_to_align=selected_seqs,
                                             settings=settings,
                                             res_to_align=selected_res)
        def _onBindingSiteAlignmentTaskDone():
            align_results = task.output.sts
            for seq, st in zip(selected_seqs, align_results):
                seq.setStructure(st)
            if settings.align_seqs:
                aln.res_selection_model.clearSelection()
                self.alignBySuperposition()
            dlg = dialogs.BindingSiteAlignResultDialog(parent=self)
            if task.input.align_sel_res_only:
                definition = "Selected reference residues"
            else:
                definition = f"Within {task.input.binding_site_cutoff}Å of ligand"
            dlg.setResults(results=task.output.rmsd_rows,
                           definition=definition,
                           ref_name=ref_seq.name)
            dlg.run(blocking=True, modal=True)
            if dlg.shouldAddRMSD():
                nonref_seqs = selected_seqs[1:]
                missing_titles = self._addBindingSiteRMSD(
                    nonref_seqs, task.output.rmsd_rows)
                if missing_titles:
                    self.warning(
                        "Could not find Binding Site Alignment RMSD for " +
                        ", ".join(missing_titles))
        task.taskDone.connect(_onBindingSiteAlignmentTaskDone)
        task.taskFailed.connect(lambda: self.error(
            title="No Alignment",
            text="There was a problem with the binding site alignment. "
            "Please check the job log."))
        self.taskStarted.emit(task)
        task.start()
    def _addBindingSiteRMSD(self, seqs, rmsd_rows):
        """
        Set binding site RMSD as a structure property.
        :param seqs: Non-reference structured sequences that were aligned with
            binding site alignment
        :type seqs: iterable(schrodinger.protein.ProteinSequence)
        :param rmsd_rows: RMSD output from the binding site alignment task
        :type rmsd_rows: list[namedtuple]
        :return: Structure titles that did not have a corresponding RMSD value
        :rtype: list[str]
        """
        rmsds_by_title = {row.other_name: row.rmsd for row in rmsd_rows}
        missing_titles = []
        for seq in seqs:
            st = seq.getStructure()
            rmsd = rmsds_by_title.get(st.title)
            if rmsd is None:
                missing_titles.append(st.title)
            else:
                st.property[PROPNAME_BINDINGSITE_RMSD] = float(rmsd)
                seq.setStructure(st)
        return missing_titles
    def _getBindingSiteAlignTask(self,
                                 seqs_to_align,
                                 settings,
                                 res_to_align=None):
        """
        Creates the Binding Site Alignment task and sets the structures of
        aligned sequences.
        :param seqs_to_align: Sequences to be aligned
        :type seqs_to_align: iterable(schrodinger.protein.ProteinSequence)
        :param settings: Binding site alignment settings
        :type settings: gui_models.BindingSiteAlignSettingsModel
        :param res_to_align: Residues to be aligned if align selected residues
            only. Each residues corresponds to a residue in the ref sequence
        :type res_to_align: list(protein.residue.Residue)
        """
        task = binding_site_align.BindingSiteAlignmentTask()
        task.input.sts = [seq.getStructure() for seq in seqs_to_align]
        task.input.binding_site_cutoff = settings.binding_site_cutoff.value
        task.input.mapping_dist = settings.mapping_dist.value
        task.input.previously_aligned = settings.previously_aligned
        task.input.align_sel_res_only = settings.align_sel_res_only
        task.input.res_list = res_to_align
        return task
    def _createSelectedResidueASLs(self, aln, other_define_asl=False):
        """
        Create ASLs based on the selected residues in the sequences linked to
        the reference entry and the sequences linked to the non-reference
        entries. If the non-reference sequences have selected residues, a
        separate ASL will be constructed for those structures. Otherwise,
        other_asl will be the same as ref_asl.
        :param aln: The alignment
        :type aln: gui_alignment.GuiProteinAlignment
        :param other_define_asl: Whether a separate ASL is defined for the
            non-reference sequences. If True, other_asl will be None.
        :return: An ASL for the reference entry and an ASL for the
            non-reference entries or None if other_define_asl is True
        :rtype: tuple(str, str or NoneType)
        """
        selected_res = aln.res_selection_model.getSelection()
        if not selected_res:
            raise align.CantAlignException(
                "No residues are selected, can't align based on selected "
                "residues.")
        residues_by_entry = defaultdict(list)
        for res in selected_res:
            entry_id = res.sequence.entry_id
            if entry_id is None:
                continue
            residues_by_entry[entry_id].append(res)
        ref_seq = aln.getReferenceSeq()
        ref_residues = residues_by_entry.pop(ref_seq.entry_id, None)
        if ref_residues is None:
            raise align.CantAlignException(
                "No reference residues are selected, can't align based on "
                "selected residues.")
        ref_asl = self._structure_model.generateResidueASL(ref_residues)
        if other_define_asl:
            other_asl = None  # will get replaced outside
        elif not residues_by_entry:
            # No non-reference residues selected; use same ASL
            other_asl = ref_asl
        else:
            other_asl = self._structure_model.generateEntryResidueASL(
                residues_by_entry)
        return ref_asl, other_asl
    def _getSeqsForStructureAlignment(self, require_ref=True):
        """
        Gather sequences for structure alignment.
        The reference sequence must have a structure
        All of the selected sequences must have structures
        :param require_ref: Whether the reference sequence must have a structure
        :return: Sequences to align or `None` if the sequences are inappropriate
        :rtype: list(sequence.ProteinSequence) or NoneType
        :raises align.CantAlignException: If the selected sequences aren't
            appropriate for aligning by structure
        """
        aln = self.getAlignment()
        can_aln, msg = validate_align.alignment_precheck(aln)
        if not can_aln:
            raise align.CantAlignException(msg)
        ref_seq = self.getReferenceSeq()
        # Ensure that the reference sequence has a structure.
        if require_ref and not ref_seq.hasStructure():
            msg = "The reference sequence must have a structure."
            raise align.CantAlignException(msg)
        sel_seqs = aln.seq_selection_model.getSelection()
        num_structureless = 0
        selected_structured = []
        unselected_structured = []
        for seq in aln.getShownSeqs():
            if seq is ref_seq:
                continue
            if seq.hasStructure():
                if (self.model.options.align_settings.align_only_selected_seqs
                        and seq not in sel_seqs):
                    unselected_structured.append(seq)
                else:
                    selected_structured.append(seq)
            elif seq in sel_seqs:
                num_structureless += 1
        if not selected_structured:
            if not unselected_structured:
                msg = "At least two sequences with structures must be present."
                raise align.CantAlignException(msg)
            elif num_structureless:
                msg = "At least two sequences with structures must be selected."
                raise align.CantAlignException(msg)
        if num_structureless:
            msg = (f"{num_structureless} of the selected sequences do not have "
                   "structures. Align the structured sequences only?")
            if not self.question(msg):
                return None
        seqs_to_align = None
        if selected_structured:
            seqs_to_align = selected_structured
        elif unselected_structured:
            # When the selection is empty, align all seqs with structures
            seqs_to_align = unselected_structured
        if ref_seq.hasStructure():
            seqs_to_align.insert(0, ref_seq)
        return seqs_to_align
    def _getSeqsForBindingSiteAlignment(self):
        """
        Gather sequences to align by binding site.
        The selected sequences must all have a structure and a ligand.
        :return: Sequences to align
        :rtype: iterable(sequence.ProteinSequence)
        :raises align.CantAlignException: If the selected sequences do not
            all have a structure and ligand
        """
        selected_seqs = self._getSeqsForStructureAlignment()
        if selected_seqs is None:
            return None
        settings = self.model.options.align_settings.binding_site
        for seq in selected_seqs:
            if len(seq.annotations.ligands) == 0:
                msg = f'{seq.name} does not have an associated ligand. ' \
                    'Binding sites cannot be aligned.'
                raise align.CantAlignException(msg)
            elif len(seq.annotations.ligands
                    ) > 1 and not settings.align_sel_res_only:
                msg = f'{seq.name} has more than one ligand. ' \
                    'Binding site cannot be detected.'
                raise align.CantAlignException(msg)
        return selected_seqs
    def _splitAllChains(self, sequences, prompt=True):
        """
        Split chains into separate entries if needed. The reference sequence
        will not be split.
        :param sequences:
        :type  sequences: iterable(sequence.ProteinSequence)
        :param prompt: Whether to ask for permission to split chains
        :type  prompt: bool
        :return: Sequences to align or `None` if split chains is not allowed.
        :rtype: set(sequence.ProteinSequence) or None
        """
        # In order to have a valid query, the selected sequences must come from
        # different entries - we can work around by splitting chains into
        # separate entries
        seqs_by_entry = defaultdict(list)
        for seq in sequences:
            seqs_by_entry[seq.entry_id].append(seq)
        selected_seqs = set()
        eids_to_split = []
        for eid, seqs in seqs_by_entry.items():
            if len(seqs) == 1:
                selected_seqs.add(seqs[0])
            else:
                eids_to_split.append(eid)
        if eids_to_split and prompt:
            if not af1.question(
                    "Chains must be in separate entries. "
                    "Split chains and proceed with the alignment?",
                    parent=self,
                    title="Attempting to Align Multimer Chains"):
                return None
        ref_seq = self.getReferenceSeq()
        if ref_seq.hasStructure():
            # ref seq is allowed to be structureless for superimposeStructures
            selected_seqs.add(ref_seq)
        if eids_to_split:
            to_move = []
            eid_chain_map = self._makeEidChainMap()
            for entry_id in eids_to_split:
                # Only keep chains that were selected for alignment
                keep_chains = {seq.chain for seq in seqs_by_entry[entry_id]}
                if entry_id == ref_seq.entry_id:
                    keep_chains.discard(ref_seq.chain)
                new_seqs = self._disassociateChains(entry_id,
                                                    eid_chain_map,
                                                    keep_chains=keep_chains)
                selected_seqs.update(new_seqs)
                to_move.extend(new_seqs)
            # Move new seqs to the top
            self._moveSequences(to_move, viewconstants.Direction.Top)
        self.setSelectedSequences(selected_seqs)
        return selected_seqs
    def _disassociateChains(self, entry_id, eid_chain_map, keep_chains):
        """
        Disassociates chains for an entry while preserving gaps and
        structureless residues.
        :param entry_id: The entry ID to split
        :type  entry_id: int
        :param eid_chain_map: A mapping between entry ID, chain ID, and sequence
        :type  eid_chain_map: dict(int, dict(str, sequence.Sequence))
        :return: Disassociated sequences, now with unique entry ids
        :rtype: list(sequence.Sequence)
        """
        # ask the structuremodel to disassociate the structure in maestro
        new_seqs = self._structure_model.disassociateChains(
            entry_id,
            is_workspace=self.model.is_workspace,
            keep_chains=keep_chains)
        new_seqs = [seq for seq in new_seqs if seq.chain in keep_chains]
        if not self.isWorkspace():
            seqs_to_add = [
                seq for seq in new_seqs if seq.chain in eid_chain_map[entry_id]
            ]
            self.getAlignment().addSeqs(seqs_to_add)
        new_eids = {seq.entry_id for seq in new_seqs}
        update_kwargs = dict(entry_id=entry_id,
                             eid_chain_map=eid_chain_map,
                             new_eids=new_eids)
        self._updateDisassociatedSeqs(**update_kwargs)
        return new_seqs
    def _makeEidChainMap(self):
        """
        Create a mapping between entry ID, chain ID, and sequence for the
        alignment
        :return: A mapping between entry ID, chain ID, and sequence
        :rtype: dict(int, dict(str, sequence.Sequence))
        """
        eid_chain_map = defaultdict(dict)
        for seq in self.getAlignment():
            eid_chain_map[seq.entry_id][seq.chain] = seq
        return dict(eid_chain_map)
    def _updateDisassociatedSeqs(self, entry_id, eid_chain_map, new_eids):
        """
        Update sequences that were just split from an entry to match their
        previous state. Gaps and structureless residues are copied. If needed,
        the reference sequence is updated. The original sequences are moved to
        the bottom.
        :param entry_id: The entry ID that was split
        :type  entry_id: int
        :param eid_chain_map: A mapping between entry ID, chain ID, and
                              original sequence
        :type  eid_chain_map: dict(int, dict(str, sequence.Sequence))
        :param new_eids: Entry IDs of the new structures corresponding to the
                         chains of the split entry
        :type new_eids: iterable(int)
        """
        aln = self.getAlignment()
        for seq in aln:
            if (seq.entry_id not in new_eids or
                    seq.chain not in eid_chain_map[entry_id]):
                continue
            old_seq = eid_chain_map[entry_id][seq.chain]
            # Copy gaps and structureless res from old to new
            for idx, res in enumerate(old_seq):
                if not res.hasStructure():
                    aln.addElements(seq, idx, [copy.deepcopy(res)])
    def _exportSelectedSeqs(self):
        """
        Open Sequence Export dialog with 'Selected Sequences' option selected.
        """
        dlg_model = self.seq_export_dialog.model
        dlg_model.which_sequences = dlg_model.Sequences.SELECTED
        self.exportSequences()
[docs]    def exportSequences(self):
        """
        Exports sequences in FASTA format.
        """
        dlg = self.seq_export_dialog
        proceed = dlg.exec_()
        if not proceed:
            return
        model = dlg.model
        aln = self.model.split_aln
        if not aln:
            msg = "Alignment is currently empty."
            self.warning(title="No Alignment", text=msg)
            return
        # If 'Selected Sequences' is chosen, only export sequences if there
        # is a selected residue in that sequence
        if model.which_sequences == model.Sequences.SELECTED:
            selected_res = aln.seq_selection_model.getSelection()
            if not selected_res:
                self.warning(
                    title="No selected sequences",
                    text="Please select some sequences to be exported.")
                return
        # If 'Selected Alignment' is chosen, only export if there is a
        # selected residue in that sequence
        if model.which_residues == model.Residues.SELECTED:
            selected_res = aln.res_selection_model.getSelectionIndices()
            if not selected_res:
                self.warning(title="No selected residues",
                             text="Please select some residues to be exported.")
                return
            if model.which_sequences == model.Sequences.SELECTED:
                seq_idx = aln.seq_selection_model.getSelectionIndices()
                if not any(idx[0] in seq_idx for idx in selected_res):
                    self.warning(
                        title="No selected residues in selected sequences",
                        text="Please select some residues in a selected "
                        "sequence to be exported.")
                    return
        file_name = dlg.selectedFile()
        self._exportSequences(model, file_name)
    def _exportSequences(self, model, file_path):
        """
        Export sequences to file. Depending on the GUI option selected, each
        file will have sequence of each chain or sequence of each protein.
        :param model: the model for sequence export
        :type model: gui_models.ExportSequenceModel
        :param file_path: Sequence file path
        :type file_path: str
        """
        aln = self.model.split_aln
        subset_idxs = None
        # Only output selected sequences
        if model.which_sequences == model.Sequences.SELECTED:
            subset_idxs = aln.seq_selection_model.getSelectionIndices()
        elif model.which_sequences == model.Sequences.DISPLAYED:
            subset_idxs = [
                i for i, shown in enumerate(aln.getSeqShownStates()) if shown
            ]
        new_aln = copy.deepcopy(aln)
        # Only output selected residues
        if model.which_residues == model.Residues.SELECTED:
            all_res = {res for seq in new_aln for res in seq}
            sel_res = new_aln.res_selection_model.getSelection()
            with new_aln.modifyingStructure():
                new_aln.replaceResiduesWithGaps(all_res - sel_res)
            if not model.preserve_indices:
                # minimize the sequence but leave a gap column between each
                # block
                new_aln = new_aln.getAlignmentMinimizedWithSpaces()
        if subset_idxs is not None:
            subset_idxs.sort()
            # create a new alignment with just the sequences which were
            # previously selected
            seqs = [new_aln[idx] for idx in subset_idxs]
            new_aln = aln.__class__(seqs)
        reference_seq = new_aln.getReferenceSeq(
        ) if model.include_similarity else None
        writers = {
            model.Format.TXT: seqio.TextAlignmentWriter,
            model.Format.ALN: seqio.ClustalAlignmentWriter,
            model.Format.CSV: seqio.CSVAlignmentWriter,
        }
        AW = writers.get(model.format, seqio.FastaAlignmentWriter)
        names_and_chains = [(seq.name, seq.chain) for seq in new_aln]
        needs_unique = len(set(names_and_chains)) != len(names_and_chains)
        if not model.create_multiple_files:
            AW.write(new_aln,
                     file_path,
                     use_unique_names=needs_unique,
                     export_annotations=model.include_ss_anno,
                     sim_ref_seq=reference_seq,
                     maxl=MAX_FASTA_LINE_LENGTH,
                     export_descriptors=model.export_descriptors)
        else:
            self._exportToMultipleFiles(model=model,
                                        aln=new_aln,
                                        writer=AW,
                                        file_path=file_path,
                                        use_unique_names=needs_unique,
                                        sim_ref_seq=reference_seq)
    def _exportToMultipleFiles(self, model, aln, writer, file_path,
                               use_unique_names, sim_ref_seq):
        """
        Export sequences to multiple files. Sequence of each chain or
        sequence of each protein will be exported to a file.
        :param model: the model for sequence export
        :type model: gui_models.ExportSequenceModel
        :param aln: Alignment of selected sequences/residues to export
        :type aln: gui_alignment.GuiProteinAlignment
        :param writer: Sequence Alignment writer
        :type writer: seqio.FastaAlignmentWriter or seqio.TextAlignmentWriter
                   or seqio.ClustalAlignmentWriter
        :param file_path: Sequence file path
        :type file_path: str
        :param use_unique_names: If True, write unique name for each sequence.
        :type use_unique_names: bool
        :param sim_ref_seq: Reference sequence to calculate similarities for
            the sequences to be exported. If None, similarity will not be exported.
        :type sim_ref_seq: `sequence.Sequence` or None
        """
        seqs_to_export = aln
        if model.split_file_by == model.SplitFileBy.Structure:
            # Sequences for each protein as {(sequence name,
            # entry id) : [sequences]}
            protein_seq_dict = defaultdict(list)
            for seq in aln:
                protein_seq_dict[(seq.name, seq.entry_id)].append(seq)
            seqs_to_export = protein_seq_dict.values()
        for seq in seqs_to_export:
            if model.split_file_by == model.SplitFileBy.Structure:
                file_name = f"{seq[0].name}"
                aln_to_export = aln.__class__(seq)
            else:
                file_name = f"{seq.name}_{seq.chain}"
                aln_to_export = aln.__class__([seq])
            seq_file_path = file_path.replace(dialogs.PLACEHOLDER_FILE_NAME,
                                              file_name)
            writer.write(aln_to_export,
                         seq_file_path,
                         use_unique_names=use_unique_names,
                         export_annotations=model.include_ss_anno,
                         sim_ref_seq=sim_ref_seq,
                         maxl=MAX_FASTA_LINE_LENGTH)
[docs]    def openBlastSearchDialog(self):
        """
        Opens Blast Search dialog.
        """
        query = self.getReferenceSeq()
        if query is None:
            self.warning(
                title="Set BLAST Query Sequence",
                text="There are no sequences available for a BLAST search.")
            return
        self.model.blast_task.input.settings.location = blast.LOCAL
        self.model.blast_task.input.query_sequence = query
        self.blast_search_dialog.run(modal=True, blocking=True)
    def _runComputeSequenceDescriptorsDialog(self):
        """
        Runs the ComputeSequenceDescriptorsDialog and runs any required
        tasks to generate the sequence and structure descriptors requested
        from the dialog.
        """
        dlg = dialogs.ComputeSequenceDescriptorsDialog(parent=self)
        ok_pressed = dlg.exec_()
        if not ok_pressed:
            return
        if dlg.model.selected_seqs_only:
            seqs = self.getSelectedSequences()
        else:
            seqs = self.model.aln[:]
        if not seqs:
            return
        # Lambda slots with references to self cause problems with garbage
        # collection.  To avoid this, we replace self with a weakref.
        self = weakref.proxy(self)
        @qt_utils.wait_cursor
        def run_descriptor_task(desc_task, seqs):
            desc_task.input.seqs = seqs
            desc_task.specifyTaskDir(desc_task.TEMP_TASKDIR)
            desc_task.start()
            desc_task.statusChanged.connect(
                lambda: self._onDescriptorTaskStatusChanged(desc_task))
            self.taskStarted.emit(desc_task)
        # Generate sequence descriptors.
        requested_descriptors = [
            desc.property_name
            for desc in dlg.model.selected_descriptors
            if desc.property_source is desc.Sequence
        ]
        if requested_descriptors:
            seq_desc_task = descriptors.SequenceDescriptorsTask()
            seq_desc_task.input.seqs = seqs
            seq_desc_task.input.requested_descriptors = requested_descriptors
            seq_desc_task.statusChanged.connect(
                lambda: self._onDescriptorTaskStatusChanged(seq_desc_task))
            seq_desc_task.start()
            self.taskStarted.emit(seq_desc_task)
        # Generate structure descriptors if any structure descriptors were
        # requested from the dialog.
        if any(desc.property_source is desc.Structure
               for desc in dlg.model.selected_descriptors):
            sts_desc_task = descriptors.StructureDescriptorsTask()
            seqs = [seq for seq in seqs if seq.hasStructure()]
            if seqs:
                run_descriptor_task(sts_desc_task, seqs)
    def _onDescriptorTaskStatusChanged(self, task):
        if task.status != task.DONE:
            return
        output = task.output
        for seq in self.model.aln:
            if seq in output.descriptors:
                descriptors = output.descriptors[seq]
                seq.updateDescriptors(descriptors, task.PROP_SOURCE)
        msg = """<html><p>New sequence descriptors have been calculated.</p>
        <p>To display the values, choose <i>Show properties...</i><br>
        from the View Options pane (+button).</p></html>"""
        self.info(msg)
    def _initBlastSearchDialog(self):
        dialog = dialogs.BlastSearchDialog(self)
        # Lambda slots with references to self cause problems with garbage
        # collection.  To avoid this, we replace self with a weakref.
        self = weakref.proxy(self)
        dialog.blastSearchRequested.connect(
            lambda: self.startBlastTaskRequested.emit(self.model.blast_task))
        return dialog
    def _getFinishedBlastTask(self):
        """
        Get the most recently finished blast task
        """
        task = self.model.blast_task
        if task.status is task.DONE:
            return task
        return None
[docs]    def hasBlastResults(self, for_ref_seq=False):
        """
        Return whether the blast task is done.
        :param for_ref_seq: Whether to require that the results are for the
            current reference seq
        """
        ref_seq = self.getReferenceSeq()
        if for_ref_seq and ref_seq is None:
            return
        blast_task = self._getFinishedBlastTask()
        done = blast_task is not None
        if not for_ref_seq:
            return done
        return (done and blast_task.input.query_sequence == ref_seq)
[docs]    def downloadBlastPDBs(self, pdb_ids):
        remote_ok = not self.model.options.blast_local_only
        fetch_ids = seqio.process_fetch_ids(pdb_ids, dialog_parent=self)
        _, pdb_result = dialogs.download_seq_pdb(fetch_ids,
                                                 parent=self,
                                                 pdb_remote_ok=remote_ok)
        pdb_paths = pdb_result.paths
        error_ids = pdb_result.error_ids
        seqs = self.loadPdbs(pdb_paths)
        if seqs and self.model.blast_task.input.settings.align_after_download:
            seq_to_align = seqs[0]
            aln = self.getAlignment()
            ref_seq = aln.getReferenceSeq()
            align_settings = self.model.options.align_settings
            og_align_only = align_settings.align_only_selected_seqs
            align_settings.align_only_selected_seqs = True
            with aln.seq_selection_model.suspendSelection(), \
                    aln.res_selection_model.suspendSelection():
                try:
                    aln.seq_selection_model.setSelectionState(
                        {ref_seq, seq_to_align}, True)
                    self.pairwiseAlignment()
                finally:
                    align_settings.align_only_selected_seqs = og_align_only
        if error_ids:
            if remote_ok:
                self.warning(title="Error Retrieving Structures",
                             text="Could not retrieve the following ID(s) from "
                             f"the remote server: {', '.join(error_ids)}")
            else:
                dialogs.LocalBlastPDBNoResultsMessageBox(
                    self, ", ".join(error_ids), count=len(error_ids)).exec()
    # TODO MSV-2041: move this to alignment.py
    def _selectPattern(self, pattern):
        """
        Search for pattern in alignment and select matching residues
        :param pattern: PROSITE pattern (see
            `protein.sequence.find_generalized_pattern` for documentation).
        :type pattern: str
        :returns: 2-tuple of success and error message
        :rtype: tuple(bool, str)
        """
        aln = self.getAlignment()
        if len(aln) == 0:
            return (False, "No sequences found to search.")
        matching_residues = aln.findPattern(pattern)
        if len(matching_residues) == 0:
            return (False, "There were no matches for the specified pattern.")
        # TODO MSV-1508 this should be invalidated whenever selection changes
        self._current_pattern_idx = None
        with command.compress_command(self.undo_stack,
                                      "Select Pattern-Matching Residues"):
            aln.res_selection_model.clearSelection()
            aln.res_selection_model.setSelectionState(matching_residues, True)
        return (True, "")
[docs]    def movePattern(self, forward=True):
        """
        Scroll view to next or previous pattern instance.
        :param forward: whether to move pattern view forward
        :type forward: bool
        """
        aln = self.getAlignment()
        selected_residue_set = aln.res_selection_model.getSelection()
        if not selected_residue_set:
            return
        def residue_sort(res):
            return (aln.index(res.sequence), res.resnum, res.inscode)
        selected_residues = sorted(
            (res for res in selected_residue_set if res.is_res),
            key=residue_sort)
        current_index = self._current_pattern_idx
        if forward:
            new_index = current_index + 1 if current_index is not None else 0
        else:
            new_index = current_index - 1 if current_index is not None else -1
        n_indexes = len(selected_residues)
        # Modulo to allow wraparound
        new_index = new_index % n_indexes
        next_res = selected_residues[new_index]
        res_model_index = self.view.model().getIndexForRes(next_res)
        # Scroll view to current index
        self.view.scrollTo(res_model_index,
                           QtWidgets.QAbstractItemView.PositionAtCenter)
        # Update index
        self._current_pattern_idx = new_index
[docs]def check_if_can_retry_blast(dialog_parent, task, blast_local_only):
    """
    For a failed BLAST task, show the appropriate dialog.
    - If the task was run remote, show "Remote BLAST failed" error
    - If tasks can only run locally, show "Local BLAST failed error"
    - If the task was run locally, ask whether to try Remote BLAST
    :return: Whether the task should be retried remote
    :rtype: bool
    """
    settings = task.input.settings
    error_msg = str(task.failure_info.exception)
    retry = False
    if settings.location == blast.REMOTE:
        # Already tried remote - show error dialog
        text = f"Searching the remote BLAST server failed:\n\n{error_msg}"
        dialog_parent.warning(
            title=f"Remote BLAST Search Failed - {task.getQueryName()}",
            text=text)
    elif blast_local_only:
        # Local failed but remote is not allowed - error out
        dialogs.LocalBlastNoResultsMessageBox(dialog_parent).exec()
    elif isinstance(task.failure_info.exception, blast.NoBlastHitsError):
        # Local had no hits - prompt for remote (unless do not ask again)
        retry = dialogs.RemoteBlastMessageBox(dialog_parent).exec()
    else:
        # Local failed - prompt for remote retry
        retry = dialog_parent.question(
            "Searching the local BLAST server failed:\n\n%s\n\n"
            "The search will continue on the remote server." % error_msg,
            title=f"Local BLAST Search Failed - {task.getQueryName()}")
    return retry
[docs]def get_svg_renderer(widget):
    """
    Get a renderer that can paint a widget by means of svg onto a paint
    devices. This effectively vectorizes the image, allowing for very high
    resolutions to be used when drawing on the paint device.
    :param widget: a widget that can be converted into an svg. This needs
        a render method
    :type widget: QtWidgets.QWidget
    :return: the svg renderer
    :rtype: QtSvg.QSvgRenderer
    """
    gen = QtSvg.QSvgGenerator()
    gen.setSize(widget.size())
    gen.setViewBox(QtCore.QRect(0, 0, widget.width(), widget.height()))
    buffer = QtCore.QBuffer()
    buffer.open(QtCore.QIODevice.ReadWrite)
    gen.setOutputDevice(buffer)
    painter = QtGui.QPainter()
    painter.begin(gen)
    widget.render(painter)
    painter.end()
    buffer.seek(0)
    svg = buffer.readAll()
    renderer = QtSvg.QSvgRenderer(svg)
    return renderer
[docs]def save_pdf(renderer, file_name):
    """
    Draw an image from the renderer to the given file name as a pdf
    :param renderer: the renderer that will draw the image
    :type renderer: QtSvg.QSvgRenderer
    :param file_name: the name of the file to write to
    :type file_name: str
    """
    printer = QtPrintSupport.QPrinter(QtPrintSupport.QPrinter.HighResolution)
    printer.setOutputFormat(QtPrintSupport.QPrinter.PdfFormat)
    printer.setOutputFileName(file_name)
    printer.setFontEmbeddingEnabled(True)
    printer.setFullPage(False)
    printer.setPageSize(QtGui.QPageSize(renderer.defaultSize()))
    printer.setPageMargins(0, 0, 0, 0, QtPrintSupport.QPrinter.DevicePixel)
    painter = QtGui.QPainter(printer)
    renderer.render(painter)
    painter.end()
[docs]def save_png(renderer, file_name, dpi):
    """
    Draw an image from the renderer to the given file name as a png
    :param renderer: the renderer that will draw the image
    :type renderer: QtSvg.QSvgRenderer
    :param file_name: the name of the file to write to
    :type file_name: str
    :param dpi: how many dpi to export to PNG
    :type dpi: int
    """
    screen = QtWidgets.QApplication.instance().primaryScreen()
    scale = dpi / screen.logicalDotsPerInch()
    w = int(renderer.defaultSize().width() * scale)
    h = int(renderer.defaultSize().height() * scale)
    im = QtGui.QImage(w, h, QtGui.QImage.Format_ARGB32)
    painter = QtGui.QPainter(im)
    renderer.render(painter)
    painter.end()
    im.save(file_name, format="PNG", quality=100)