import collections
import contextlib
import copy
import itertools
import types
import typing
import weakref
from functools import partial
import inflect
from schrodinger import in_dev_env
from schrodinger import structure
from schrodinger.application.msv import command
from schrodinger.application.msv import utils as msv_utils
from schrodinger.models import json
from schrodinger.protein import alignment
from schrodinger.protein import annotation
from schrodinger.protein import residue
from schrodinger.protein import sequence
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
_ResidueOutline = collections.namedtuple("_ResidueOutline",
                                         ["start_idx", "end_idx", "color"])
[docs]class StandingSelectionError(RuntimeError):
    pass 
[docs]class AbstractAlignmentSelectionModel(QtCore.QObject):
    """
    A class that manages selection of elements in an undoable alignment. An
    element can be either a residue or a sequence. Because of limitations with
    Qt's selection models, we store selection status in our own domain objects
    instead.
    This class has an undo stack because selection is undoable in the MSV.
    :ivar _selection: The current selection state
    :vartype _selection: set
    :ivar _old_selection: The selection state from last time selectionChanged
        was emitted
    :vartype _old_selection: set
    :cvar selectionChanged: A signal emitted to notify listeners that selection
        has changed, with the set of elements that have been selected and the
        set of elements that have been deselected.  Note that this is called on
        a single shot timer so that if selection is modified multiple times
        successively (eg by a "clear then select"), only one signal is emitted.
    :vartype selectionChanged: `QtCore.pyqtSignal` emitting `set()` and `set()`
    """
    selectionChanged = QtCore.pyqtSignal(set, set)
[docs]    def __init__(self, aln):
        """
        :param aln: The alignment whose selection state we're tracking
        :type aln: ProteinAlignment
        """
        super().__init__()
        self.aln = aln
        self.undo_stack = None
        self._selection = set()
        self._old_selection = set()
        # We use _emit_selection_changed_timer to avoid unnecessarily emitting
        # self.selectionChanged
        self._emit_selection_changed_timer = QtCore.QTimer(self)
        self._emit_selection_changed_timer.setSingleShot(True)
        self._emit_selection_changed_timer.setInterval(0)
        self._emit_selection_changed_timer.timeout.connect(
            self._emitSelectionChanged)
        self.aln.signals.sequencesAboutToBeRemoved.connect(
            self.onSequencesAboutToBeRemoved) 
[docs]    def onSequencesAboutToBeRemoved(self, start, end):
        """
        When sequences are about to be removed, deselect all the elements
        contained by those sequences.
        :param start: Start index of sequences about to be removed.
        :type  start: int
        :param end: End index of sequences about to be removed.
        :type  end: int
        """
        raise NotImplementedError() 
    def __deepcopy__(self, memo):
        """
        Do not allow copying of selection models
        """
        raise RuntimeError("{} should not be copied".format(self.__class__))
[docs]    def getSelectionIndices(self):
        """
        Return a list of selected element indices. Child classes should
        reimplement.
        :return: List of selection element indices
        :rtype: list
        """
        raise NotImplementedError() 
[docs]    def setUndoStack(self, undo_stack):
        """
        :param undo_stack: The undo stack to push commands onto
        :type undo_stack: schrodinger.application.msv.command.UndoStack
        """
        # The undo stack is currently not used but will be necessary when
        # we implement proper selection undo behavior (MSV-1535).
        self.undo_stack = undo_stack 
[docs]    def setSelectionState(self, items, selected):
        """
        Set the selection state of the provided items, ignoring `None`
        :type items: iterable
        :param selected: Whether to select or deselect the items
        :type selected: bool
        """
        if selected:
            self._selection.update(items)
        else:
            self._selection.difference_update(items)
        self._selection.discard(None)
        self._emit_selection_changed_timer.start() 
[docs]    def clearSelection(self):
        """
        Unselect all elements.
        """
        self._selection.clear()
        self._emit_selection_changed_timer.start() 
[docs]    def forceSelectionUpdate(self):
        """
        Force the selectionChanged signal to emit immediately rather than
        waiting for the timer to expire.
        """
        self._emit_selection_changed_timer.stop()
        self._emitSelectionChanged() 
    def _emitSelectionChanged(self):
        """
        Emit a selectionChanged with the elements whose selection state has
        changed since the last time selectionChanged was emitted. Note that
        selectionChanged might not necessarily be emitted after every single
        single call that modifies selection.
        """
        newly_selected = set(self._selection.difference(self._old_selection))
        newly_deselected = set(self._old_selection.difference(self._selection))
        if newly_selected or newly_deselected:
            self.selectionChanged.emit(newly_selected, newly_deselected)
        self._old_selection = self._selection.copy()
[docs]    def isSelected(self, ele):
        """
        :param ele: The alignment element to determine the selection state of
        :type res: object
        :return: whether ele is selected
        :rtype: bool
        """
        return ele in self._selection 
[docs]    def getSelection(self):
        """
        :return: A set of currently selected elements
        :rtype: set
        """
        return set(self._selection) 
[docs]    def hasSelection(self):
        """
        Whether any items are currently selected.
        :rtype: bool
        """
        return bool(self._selection) 
[docs]    @contextlib.contextmanager
    def suspendSelection(self):
        """
        Inside the context, the selection model is deselected.
        :param sel_model: The selection model to temporarily deselect
        :type  sel_model: AbstractAlignmentSelectionModel
        """
        raise NotImplementedError  
[docs]class ResidueSelectionModel(AbstractAlignmentSelectionModel):
    # Setting SELECTION_SANITY_CHECK to True will cause setSelectionState and
    # setCurrentSelectionState to raise ValueError if you attempt to select a
    # residue that's not in the alignment. This check can slow down selection,
    # particularly when selecting all residues in the alignment, so we only
    # apply it for developers.
    SELECTION_SANITY_CHECK = in_dev_env()
    _CLEAR_DESC = "Clear Selected Residues"
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._standing_selection = None
        self.aln.signals.residuesAboutToBeRemoved.connect(
            self.onResiduesAboutToBeRemoved) 
    def _checkResidues(self, residues):
        """
        Make sure that all residues are from the associated alignment.
        :param residues: The residues to check
        :type residues: Iter(residue.Residue)
        :raise ValueError: If any residues are not from the correct alignment.
        """
        for res in residues:
            if res.sequence is None or res.sequence not in self.aln:
                raise ValueError(f"Can't select {res}. Residues must be "
                                 "contained in a sequence to be selectable.")
    def _checkIfCanSelect(self, residues, selected):
        if self._standing_selection is not None:
            raise StandingSelectionError("Cannot set selection state while a"
                                         " current selection is present.")
        if self.SELECTION_SANITY_CHECK and selected:
            # We may be deselecting residues that have just been removed from
            # the alignment, so only check residues to be selected.
            self._checkResidues(residues)
[docs]    def selectAll(self, *, _undoable=True):
        """
        Convenience method to select all residues. Skips sanity check for speed.
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation.
        """
        all_elements = itertools.chain(*self.aln)
        orig_sanity = self.SELECTION_SANITY_CHECK
        self.SELECTION_SANITY_CHECK = False
        try:
            self.setSelectionState(all_elements, True, _undoable=_undoable)
        finally:
            self.SELECTION_SANITY_CHECK = orig_sanity 
[docs]    def setSelectionState(self, residues, selected, *, _undoable=True):
        """
        See parent class for additional method documentation.
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation or a non-undoable action
            that takes responsibility for restoring selection.
        """
        residues = set(residues)  # in case residues is a generator
        self._checkIfCanSelect(residues, selected)
        method = (self._undoableSetSelectionState
                  if _undoable else super().setSelectionState)
        method(residues, selected) 
    @staticmethod
    def _mergeSelectionCommands(d1, d2):
        """
        Command to merge time-adjacent selection commands.
        """
        # Rather than attempt to parse the strings and come up with a combined
        # description, we throw up our hands and go with this generic message
        return "Change Residue Selection"
    @command.do_command(command_id=command.CommandType.SelectResidues,
                        command_class=command.TimeBasedCommand)
    def _undoableSetSelectionState(self, residues, selected):
        residues, desc = self._getSelectionResiduesAndDesc(residues, selected)
        set_selection = super().setSelectionState
        def redo():
            set_selection(residues, selected)
        def undo():
            set_selection(residues, not selected)
        return redo, undo, desc, self._mergeSelectionCommands
    def _getSelectionResiduesAndDesc(self, residues, selected):
        """
        Preprocess residues and create undo description for selecting residues.
        :param residues: Elements to select or deselect. Note: must be a set
            (not a list or generator expression)
        :type residues: set(residue.AbstractSequenceElement)
        :param bool selected: Whether to select or deselect the elements.
        """
        if selected:
            residues = residues - self._selection
        else:
            residues = residues & self._selection
        select_txt = "Select" if selected else "Deselect"
        num_res = len(residues)
        res_txt = inflect.engine().plural("residue", num_res)
        desc = f"{select_txt} {num_res} {res_txt}"
        return residues, desc
[docs]    def setCurrentSelectionState(self, residues, selected, *, _undoable=True):
        """
        Set residues as selected or deselected in the "current" selection.  Note
        that "current" here means "the portion of the selection that's in the
        process of being updated," i.e., the selection that's from the mouse
        click (or click and drag) that we're currently in the middle of.  This
        is equivalent to passing the `QItemSelectionModel::Current |
        QItemSelectionModel::Clear` flags to `QItemSelectionModel::select`.
        .. note:: `setSelectionState` must not be called until the current
            selection has finished.  (See `finishCurrentSelection`.)
        :param residues: The residues to set the current selection to.  Any
            previous current selection will be completely replaced.
        :type residues: Iter(residue.Residue)
        :param selected: Whether the specified residues should be selected or
            deselected.
        :type selected: bool
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation.
        """
        if self._standing_selection is None:
            # If we're starting a new current selection, save the existing
            # selection as _standing_selection.  The current selection is always
            # added to or removed from _standing_selection to get _selection.
            self._standing_selection = self._selection
        residues = set(residues)
        if self.SELECTION_SANITY_CHECK:
            self._checkResidues(residues)
        new_residues, desc = self._getCurrentSelectionResiduesAndDesc(
            residues, selected)
        if new_residues == self._selection:
            # No selection change is actually occuring, so don't bother to add
            # anything to the undo stack
            return
        if _undoable and self.undo_stack.in_macro is False:
            self.undo_stack.beginMacro(desc)
        method = (self._undoableSetCurrentSelectionState
                  if _undoable else self._setCurrentSelectionState)
        method(new_residues) 
    def _setCurrentSelectionState(self, new_residues):
        self._selection = new_residues
        self._emit_selection_changed_timer.start()
    @command.do_command(command_id=command.CommandType.SelectResidues,
                        command_class=command.TimeBasedCommand)
    def _undoableSetCurrentSelectionState(self, new_residues):
        def redo():
            self._setCurrentSelectionState(new_residues)
        restore = set(self._standing_selection)
        def undo():
            self._setCurrentSelectionState(restore)
        desc = "Set Current Residue Selection State"
        return redo, undo, desc, self._mergeSelectionCommands
    def _getCurrentSelectionResiduesAndDesc(self, residues, selected):
        if selected:
            residues = self._standing_selection | residues
        else:
            residues = self._standing_selection - residues
        select = "Select" if selected else "Deselect"
        desc = f"{select} Residues"
        return residues, desc
[docs]    def finishCurrentSelection(self, *, _undoable=True):
        """
        Finish the "current" selection and permanently merge it into the main
        selection.  If there's no "current" selection, then this method is a
        no-op, but is still safe to call.
        See `setCurrentSelectionState` for additional information about the
        "current" selection.
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation.
        """
        self._standing_selection = None
        if _undoable and self.undo_stack.in_macro is True:
            self.undo_stack.endMacro() 
[docs]    def clearSelection(self, *, _undoable=True):
        """
        Deselect all elements.
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation.
        """
        if _undoable:
            self._undoableClearSelection()
        else:
            super().clearSelection() 
    @command.do_command(command_id=command.CommandType.SelectResidues,
                        command_class=command.TimeBasedCommand)
    def _undoableClearSelection(self):
        residues = set(self._selection)
        redo = super().clearSelection
        set_selection = super().setSelectionState
        undo = lambda: set_selection(
            {elem for elem in residues if elem.sequence is not None}, True)
        return redo, undo, self._CLEAR_DESC, self._mergeSelectionCommands
[docs]    def onSequencesAboutToBeRemoved(self, start, end):
        """
        When sequences are about to be removed, deselect all the residues
        in those sequences.
        :param start: Start index of sequences about to be removed.
        :type  start: int
        :param end: End index of sequences about to be removed.
        :type  end: int
        """
        residues = list(itertools.chain(*self.aln[start:end + 1]))
        self.setSelectionState(residues, False, _undoable=False)
        # emit selectionChanged while the sequences are still part of the
        # alignment
        self._emitSelectionChanged() 
[docs]    def onResiduesAboutToBeRemoved(self, residues):
        """
        When residues are about to be removed, deselect those residues.
        :param residues:  A list of residues that were removed.
        :type  residues: list(residue.AbstractSequenceElement)
        """
        self.setSelectionState(residues, False, _undoable=False) 
[docs]    def getSelectionIndices(self, sort=True):
        """
        Return a list of selected residue indices within the alignment.
        :return: A list of (sequence index, residue index) tuples for all
            selected residues.
        :rtype: list(tuple(int, int))
        """
        return self.aln.getResidueIndices(self._selection, sort=sort) 
[docs]    def isSingleBlockSingleSeqSelected(self):
        """
        Determine whether exactly one single block of residues/gaps (i.e.
        contiguous residues/gaps) in a single sequence is selected.
        :rtype: bool
        """
        if not self._selection:
            return False
        sel_seq = {res.sequence for res in self._selection}
        if len(sel_seq) != 1:
            return False
        sel_seq = sel_seq.pop()
        found_selection = False
        selection_ended = False
        for res in sel_seq:
            if res in self._selection:
                if not found_selection:
                    # This is the start of the first selected block that we've
                    # found
                    found_selection = True
                elif selection_ended:
                    # This is the second selected block that we've found
                    return False
            elif found_selection:
                # This is the end of the first selected block
                selection_ended = True
        return True 
    def _getSelectionBlockForIndex(self, sel_idx, sel_indices):
        """
        Given a selected index (in the form of a (sequence index, residue index)
        tuple), return a set of other selected indices within the same
        selection block (i.e. contiguous selcted residues/gaps).
        :param sel_idx: Selected index to return the block of
        :type sel_idx: tuple(int, int)
        :param sel_indices: All selected indices
        :type sel_indices: set(tuple(int, int))
        :return: Set of indices in the same selection block as the specified
                 index.
        :rtype: set(tuple(int, int))
        """
        # tuples of all visited residues
        visited = {sel_idx}
        # tuples of all residues that were newly visited in the current
        # iteration
        current = {sel_idx}
        # Use graph traversal to find all other selected indices "reachable"
        # from sel_idx.  (A selected index is reachable iff there exists a path
        # from sel_idx to it such that all indices along the path are selected.)
        while current:
            prev = current
            current = set()
            for seq_i, res_i in prev:
                for delta_seq, delta_res in ((-1, 0), (1, 0), (0, -1), (0, 1)):
                    node = (seq_i + delta_seq, res_i + delta_res)
                    if node in sel_indices and node not in visited:
                        visited.add(node)
                        current.add(node)
        return visited
[docs]    def isSingleBlockSelected(self):
        """
        Determine whether exactly one single block of residues/gaps (i.e.
        contiguous residues/gaps) is selected.
        :rtype: bool
        """
        sel_indices = self.getSelectionIndices(sort=False)
        if not sel_indices:
            return False
        first_sel_index = sel_indices[0]
        # speed up inclusion checks for _getSelectionBlockForIndex by converting
        # sel_indices to a set
        sel_indices = set(sel_indices)
        visited = self._getSelectionBlockForIndex(first_sel_index, sel_indices)
        return len(visited) == len(sel_indices) 
[docs]    def numBlocksSelected(self):
        """
        Return the number of blocks of residues/gaps (i.e. contiguous
        residues/gaps) that are selected
        :return: Number of selected blocks
        :rtype: int
        """
        sel_indices = set(self.getSelectionIndices(sort=False))
        unseen_indices = sel_indices.copy()
        num_blocks = 0
        while unseen_indices:
            cur_idx = unseen_indices.pop()
            cur_block = self._getSelectionBlockForIndex(cur_idx, sel_indices)
            num_blocks += 1
            unseen_indices.difference_update(cur_block)
        return num_blocks 
[docs]    def anyStructuredResiduesSelected(self):
        """
        Determine if any structured residues (i.e. residues in a structure-
        linked sequence that aren't SEQRES only) are selected.
        :rtype: bool
        """
        return residue.any_structured_residues(self._selection) 
    @command.do_command
    def _undoableSuspendSelection(self):
        """
        Undoable suspend selection which suspends the current residue
        selection and clears the selection.
        """
        orig_sel = set(self.getSelection())
        redo = lambda: self.clearSelection(_undoable=False)
        def undo():
            valid_res = (res for res in orig_sel if res.sequence is not None)
            self.setSelectionState(valid_res, True, _undoable=False)
        return redo, undo, 'Suspend current residue selection.'
    @command.do_command
    def _undoableUnsuspendSelection(self, sel_to_restore):
        """
        Undoable unsuspend selection which clears the current selection and
        restores the selection to the given selection.
        Note: Invalid residues will not be selected.
        :param sel_to_restore: Selection to restore.
        :type sel_to_restore: Iterable.
        """
        orig_sel = set(self.getSelection())
        def get_valid_residues(residues):
            return (res for res in residues if res.sequence is not None)
        def redo():
            self.clearSelection(_undoable=False)
            res_to_select = get_valid_residues(sel_to_restore)
            self.setSelectionState(res_to_select, True, _undoable=False)
        def undo():
            self.clearSelection(_undoable=False)
            res_to_select = get_valid_residues(orig_sel)
            self.setSelectionState(res_to_select, True, _undoable=False)
        return redo, undo, 'Restore previous residue selection.'
[docs]    @contextlib.contextmanager
    def suspendSelection(self):
        """
        Suspend the selection in the context and restore it upon exit.
        Note that this is undoable, any selected elements that were removed
        from their sequence in the context when restored will be reselected.
        """
        orig_sel = set(self.getSelection())
        self._undoableSuspendSelection()
        yield
        self._undoableUnsuspendSelection(orig_sel)  
[docs]class SequenceSelectionModel(AbstractAlignmentSelectionModel):
[docs]    def setSelectionState(self, sequences, selected):
        """
        Set the selection state of the specified sequences. Hidden sequences
        will not be selected.
        """
        if selected:
            sequences = list(sequences)  # in case `sequences` is a generator
            for seq in sequences:
                if seq not in self.aln:
                    raise ValueError(
                        f"Can't select '{seq.name}{seq.chain}'. Sequences must be "
                        "contained in the alignment to be selectable.")
            if self.aln.anyHidden():
                sequences = set(self.aln.getShownSeqs()).intersection(sequences)
        super().setSelectionState(sequences, selected) 
[docs]    def onSequencesAboutToBeRemoved(self, start, end):
        """
        Respond to a `sequencesAboutToBeRemoved` signal by deselecting
        any sequences that are about to be removed.
        :param start: Start index of sequences about to be removed.
        :type  start: int
        :param end: End index of sequences about to be removed.
        :type  end: int
        """
        self.setSelectionState(self.aln[start:end + 1], False)
        # emit selectionChanged while the sequences are still part of the
        # alignment
        self._emitSelectionChanged() 
[docs]    def getSelectionIndices(self):
        """
        Return a list of the selected sequence indices within the alignment
        :return: Selected sequence indices
        :rtype: list(int)
        """
        return [self.aln.index(s) for s in self._selection] 
[docs]    @contextlib.contextmanager
    def suspendSelection(self):
        """
        Suspend the selection in the context and restore it upon exit.
        """
        orig_sel = set(self.getSelection())
        self.clearSelection()
        yield
        self.clearSelection()
        self.setSelectionState(orig_sel, True)  
[docs]class AnnotationSelectionModel(AbstractAlignmentSelectionModel):
    """
    Class that tracks the selection state of sequence annotation as
    AnnotationRowInfo namedtuples.
    """
[docs]    def onSequencesAboutToBeRemoved(self, start, end):
        removed_seqs = set(self.aln[start:end + 1])
        to_remove = set()
        for ann_id in self.getSelection():
            seq = ann_id.seq
            if seq is not None and seq in removed_seqs:
                to_remove.add(ann_id)
        self.setSelectionState(to_remove, False)
        self._emitSelectionChanged()  
class _PairwiseConstraints:
    """
    Data class to handle setting pairwise constraints.
    """
    def __init__(self):
        self._resetAttributes()
    def _resetAttributes(self):
        self._ref_residue = None
        self._other_residue = None
        self._pairwise_constraints = {}
    @property
    def indices(self):
        """
        :return: Tuples of residue indices of constraints
        :rtype: list[tuple(int, int)]
        """
        return [(r1.idx_in_seq, r2.idx_in_seq)
                for r1, r2 in self._pairwise_constraints.items()]
    @property
    def ref_residue(self):
        """
        Most recently picked ref residue
        """
        return self._ref_residue
    @property
    def other_residue(self):
        """
        Most recently picked non-ref residue
        """
        return self._other_residue
    def hasConstraints(self):
        return bool(self._pairwise_constraints)
    def getPairs(self):
        """
        :return: Pairs of reference residue, non-reference residue
        :rtype: list(tuple(residue.Residue, residue.Residue))
        """
        return list(self._pairwise_constraints.items())
    def reset(self):
        self._resetAttributes()
    def setRefConstraint(self, res):
        """
        Create or break constraint for the given reference residue
        """
        half_ref = self._ref_residue
        half_other = self._other_residue
        if half_other is not None:
            if self._pairwise_constraints.get(res) == half_other:
                self._pairwise_constraints.pop(res)
            else:
                for ref_res, other_res in self._pairwise_constraints.items():
                    if other_res == half_other:
                        self._pairwise_constraints.pop(ref_res)
                        break
                self._pairwise_constraints[res] = half_other
            self._other_residue = None
        elif half_ref == res:
            self._ref_residue = None
        else:
            self._ref_residue = res
    def setOtherConstraint(self, res):
        """
        Create or break constraint for the given non-reference residue
        """
        half_other = self._other_residue
        half_ref = self._ref_residue
        if half_ref is not None:
            if self._pairwise_constraints.get(half_ref) == res:
                self._pairwise_constraints.pop(half_ref)
            else:
                self._pairwise_constraints[half_ref] = res
            self._ref_residue = None
        elif half_other == res:
            self._other_residue = None
        else:
            self._other_residue = res
class _HMProximityConstraints(QtCore.QObject):
    """
    Data class to handle setting residue (proximity) constraints for homology
    modeling.
    """
    constraintsChanged = QtCore.pyqtSignal()
    def __init__(self):
        super().__init__()
        self._resetAttributes()
    def reset(self):
        do_signal = self.hasConstraints() or self.picked_residue
        self._resetAttributes()
        if do_signal:
            self.constraintsChanged.emit()
    def _resetAttributes(self):
        self._picked_residue = None
        self._constraints = {}
        self._reverse_constraints = {}
    @property
    def indexes(self):
        """
        :return: Tuples of residue indexes of constraints
        :rtype: list[tuple(int, int)]
        """
        return [(r1.idx_in_seq, r2.idx_in_seq)
                for r1, r2 in self._constraints.items()]
    @property
    def picked_residue(self):
        """
        Most recently picked residue
        """
        return self._picked_residue
    def hasConstraints(self):
        return bool(self._constraints)
    def getPairs(self):
        """
        :return: Pairs of constrained residues
        :rtype: list(tuple(residue.Residue, residue.Residue))
        """
        return list(self._constraints.items())
    def setConstraint(self, res):
        """
        Create or break constraint for the given reference residue.
        If this is the first picked residue, it will be stored as a "half
        constraint". If this is the second picked residue, a constraint will
        be formed. If the picked residue is already part of a constraint, the
        constraint will be broken.
        """
        self._setConstraint(res)
        self.constraintsChanged.emit()
    def _setConstraint(self, res):
        half_pick = self._picked_residue
        if half_pick is not None:
            if half_pick != res:
                # If the residues are different, set a constraint
                self._constraints[half_pick] = res
                self._reverse_constraints[res] = half_pick
            self._picked_residue = None
        else:
            # If res is in the constraint dict, remove the constraint from both
            other = self._constraints.pop(res, None)
            if other is not None:
                del self._reverse_constraints[other]
                return
            # If res is in the reverse dict, remove the constraint from both
            other = self._reverse_constraints.pop(res, None)
            if other is not None:
                del self._constraints[other]
                return
            # Otherwise, pick the res
            self._picked_residue = res
    def _onResiduesAboutToBeRemoved(self, residues):
        """
        Remove constraints for residues that are about to be removed.
        """
        do_signal = False
        for res in residues:
            val = self._constraints.pop(res, None)
            if val is not None:
                del self._reverse_constraints[val]
                do_signal = True
        if do_signal:
            self.constraintsChanged.emit()
    def _onSequencesAboutToBeRemoved(self, sequences):
        """
        Remove constraints for residues in sequences that are about to be
        removed.
        """
        sequences = set(sequences)
        res_to_pop = set()
        for res in self._constraints.keys():
            if res.sequence in sequences:
                res_to_pop.add(res)
        for res in res_to_pop:
            other_res = self._constraints.pop(res)
            del self._reverse_constraints[other_res]
        if res_to_pop:
            self.constraintsChanged.emit()
class _HMLigandConstraints(QtCore.QObject):
    """
    Data class to handle setting ligand constraints for homology modeling
    """
    constraintsChanged = QtCore.pyqtSignal()
    def __init__(self):
        super().__init__()
        self._constraints = {}
    def reset(self):
        do_signal = self.hasConstraints()
        self._constraints = {}
        if do_signal:
            self.constraintsChanged.emit()
    @property
    def constraints(self):
        for res, lig_list in self._constraints.items():
            for lig in lig_list:
                yield (res, lig)
    def hasConstraints(self):
        return bool(self._constraints)
    def isConstraint(self, res, lig):
        res_constraints = self._constraints.get(res, set())
        return lig in res_constraints
    def handlePick(self, res, lig):
        """
        Pick or unpick the given residue ligand pair
        """
        res_constraints = self._constraints.setdefault(res, set())
        if lig in res_constraints:
            res_constraints.remove(lig)
        else:
            res_constraints.add(lig)
        self.constraintsChanged.emit()
    def _onResiduesAboutToBeRemoved(self, residues):
        """
        Remove ligand constraints for residues that are about to be removed.
        """
        do_signal = False
        for res in residues:
            val = self._constraints.pop(res, None)
            if val is not None:
                do_signal = True
        if do_signal:
            self.constraintsChanged.emit()
    def _onSequencesAboutToBeRemoved(self, sequences):
        """
        Remove ligand constraints for residues in sequences that are about to be
        removed.
        """
        sequences = set(sequences)
        res_to_pop = set()
        for res in self._constraints.keys():
            if res.sequence in sequences:
                res_to_pop.add(res)
        for res in res_to_pop:
            self._constraints.pop(res)
        if res_to_pop:
            self.constraintsChanged.emit()
class _ResidueOutlines(QtCore.QObject):
    """
    Class to encapsulate residue outlines
    :ivar resOutlineStatusChanged: Signal emitted when residue outline changes.
        Emitted with whether any residues are outlined.
    """
    resOutlineStatusChanged = QtCore.pyqtSignal(bool)
    def __init__(self):
        super().__init__()
        self._outlines = {}
        self._res_outlines_by_seq = None
        self.resOutlineStatusChanged.connect(self.invalidateOutlines)
    def getOutlineMap(self):
        """
        Get the read-only map of outlines
        """
        return types.MappingProxyType(self._outlines)
    def getResOutlinesForSeq(self, seq):
        """
        Get the residue outline blocks for the given sequence
        """
        if self._res_outlines_by_seq is None:
            self._cacheResOutlinesBySeq()
        return self._res_outlines_by_seq.get(seq, ())
    def setResOutlines(self, color_map):
        """
        Set a new mapping between residues and outline RGB. For undoability,
        should only be called from within a command redo or undo method.
        """
        self._outlines = color_map
        self.resOutlineStatusChanged.emit(bool(color_map))
    def _getResOutlineColorCmd(self, residues, color):
        """
        Return command implementation for setting residue outline color. Should
        be called from a method wrapped in `command.do_command`.
        """
        redo, undo = self._getOutlineCommands(residues, color)
        if color:
            color_name = QtGui.QColor(*color).name()
            desc = f"Outline %s in {color_name}"
        else:
            desc = "Remove Outline from %s"
        n_res = len(residues)
        residue_text = inflect.engine().plural("Residue", n_res)
        desc = desc % f"{n_res} Selected {residue_text}"
        return redo, undo, desc
    @QtCore.pyqtSlot()
    def invalidateOutlines(self):
        self._res_outlines_by_seq = None
    def _cacheResOutlinesBySeq(self):
        """
        Create a mapping between sequences and outline blocks
        """
        outlined_res_by_seq = collections.defaultdict(list)
        for res, color_ in self._outlines.items():
            seq = res.sequence
            res_idx_in_seq = seq.index(res)
            outlined_res_by_seq[seq].append((res_idx_in_seq, color_))
        res_outlines_by_seq = {
            seq: self._createOutlinesBySeq(residue_infos)
            for seq, residue_infos in outlined_res_by_seq.items()
        }
        self._res_outlines_by_seq = res_outlines_by_seq
    def _createOutlinesBySeq(self, residue_infos):
        """
        Group outlined residues into contiguous blocks by color
        :param residue_infos: Tuples of res_idx, color
        :type residue_infos: list[tuple(int, tuple)]
        """
        outlines = []
        residue_infos.sort()
        prev_res_iter, res_iter = itertools.tee(residue_infos)
        outline_start = next(res_iter, None)
        for prev_res_info, res_info in itertools.zip_longest(
                prev_res_iter, res_iter):
            prev_res_idx, prev_color = prev_res_info
            if res_info is None:
                # Last iteration; always need to store
                store = True
            else:
                res_idx, res_color = res_info
                together = (res_color == prev_color and
                            res_idx - prev_res_idx == 1)
                # Store if the current res info doesn't go with the previous res
                store = not together
            if store:
                outline_info = _ResidueOutline(outline_start[0], prev_res_idx,
                                               prev_color)
                outlines.append(outline_info)
                # The current res is the start of the next outline block
                outline_start = res_info
        return outlines
    def _getOutlineColorMap(self, copy=True):
        """
        :param copy: Whether to copy the map
        :type  copy: bool
        :return: Mapping mapping between residue object and RGB tuple
        :rtype: dict
        """
        color_map = self._outlines
        if copy:
            color_map = color_map.copy()
        return color_map
    def _getOutlineCommands(self, residues, color):
        """
        Create the redo and undo commands to outline the specified residues.
        :param color: RGB tuple to outline the residues or empty tuple to clear
        :type  color: tuple
        """
        orig_color_map = self._getOutlineColorMap()
        new_color_map = self._getOutlineColorMap(copy=False)
        for res in residues:
            if color:
                new_color_map[res] = color
            else:
                new_color_map.pop(res, None)
        redo = partial(self.setResOutlines, new_color_map)
        undo = partial(self.setResOutlines, orig_color_map)
        return redo, undo
[docs]class AlignmentSignals(alignment.AlignmentSignals):
    """
    Signals that can be emitted by the GUI alignments.
    :cvar seqExpansionChanged: Signal emitted when the expansion state (i.e. are
        the annotations expanded or collapsed) for a sequence changes.  Emitted
        with:
        - The sequence that changed
        - Whether the sequence is now expanded
    :cvar resHighlightStatusChanged: Signal emitted when residue highlighting
        changes. Emitted with whether any residues are highlighted.
    :ivar resOutlineStatusChanged: Signal emitted when residue outline changes.
        Emitted with whether any residues are outlined.
    :ivar homologyLigandConstraintsChanged: Signal emitted when homology
        modeling ligand constraints change.
    :ivar homologyProximityConstraintsChanged: Signal emitted when homology
        modeling residue constraints change.
    :cvar homologyCompositeResiduesChanged: Signal emitted when composite
        residues change.
    :cvar homologyStatusChanged: Signal emitted when homology status changes.
        Emitted with the sequence that changed. Signal not emitted for
        sequences that are removed from the alignment.
    :cvar resSelectionChanged: Signal emitted when the residue selection in the
        alignment changes.  Emitted with:
        - set of newly selected residues
        - set of newly deselected residues
    :cvar seqSelectionChanged: Signal emitted when the sequence selection in
        the alignment changes. Emitted with:
        - set of newly selected sequences
        - set of newly deselected sequences
    :cvar syncWsResSelection: Signal emitted when a new sequence has been added
        to the alignment. In response to this signal, the MSV Widget is
        responsible for selecting all residues that correspond to selected
        workspace residues. Emitted with the iterable of sequences to select
        residues in.
    """
    seqExpansionChanged = QtCore.pyqtSignal(sequence.AbstractSequence, bool)
    resHighlightStatusChanged = QtCore.pyqtSignal(bool)
    resOutlineStatusChanged = QtCore.pyqtSignal(bool)
    homologyCompositeResiduesChanged = QtCore.pyqtSignal()
    homologyLigandConstraintsChanged = QtCore.pyqtSignal()
    homologyProximityConstraintsChanged = QtCore.pyqtSignal()
    homologyStatusChanged = QtCore.pyqtSignal(sequence.AbstractSequence)
    pairwiseConstraintsChanged = QtCore.pyqtSignal()
    hiddenSeqsChanged = QtCore.pyqtSignal(bool)
    resSelectionChanged = QtCore.pyqtSignal(set, set)
    seqSelectionChanged = QtCore.pyqtSignal(set, set)
    syncWsResSelection = QtCore.pyqtSignal(object) 
class _ProteinAlignment(QtCore.QObject,
                        metaclass=msv_utils.QtDocstringWrapperMetaClass,
                        wraps=alignment.ProteinAlignment):
    """
    A ProteinAlignment class that presents the same interface as a regular
    ProteinAlignment but optionally accomplishes mutating operations via a
    command stack.
    If no command stack is set on the object, commands are executed but cannot
    be undone.
    Undoable protein alignments have an `AlignmentSelectionModel` because
    they are intended for use in GUIs, whereas normal non-undoable alignments
    don't have a selection model because their use cases (aligning sequences)
    don't require a concept of selection.
    :cvar _CLEAR_DESC: The undo stack description for clearing the alignment.
    :vartype _CLEAR_DESC: str
    """
    _CLEAR_DESC = "Remove All Sequences"
    _MOVE_SELECTED_SEQS_DESC = "Move Selected Sequences"
    _EXPAND_SELECTION_TO_FULL_CHAIN = "Expand to Full Chain"
    def __init__(self, sequences=None, aln=None, is_workspace=False):
        """
        :param sequences: An optional iterable of sequences
        :type sequences: list
        :param aln: An alignment to wrap this instance around.
        :type aln: alignment.ProteinAlignment
        :param is_workspace: Whether this alignment will only include sequences
            that are currently included in the workspace.  This should only be
            set to True for an alignment created by the structure model.  Note
            that this argument has absolutely no effect on the behavior of this
            alignment object.  Instead, it is the responsibility of the
            structure model to make sure that the alignment is kept up to date.
        :type is_workspace: bool
        """
        if aln is not None and sequences is not None:
            err_msg = (
                'Either aln or sequences can be passed into the constructor '
                'not both.')
            raise ValueError(err_msg)
        super().__init__()
        self.signals = AlignmentSignals(self)
        self.undo_stack = None
        self._aln = None
        self._is_workspace = is_workspace
        self._residue_highlights = {}
        self._residue_outlines = _ResidueOutlines()
        self._residue_outlines.resOutlineStatusChanged.connect(
            self.signals.resOutlineStatusChanged)
        if aln is None:
            aln = alignment.ProteinAlignment(sequences)
        self._setInnerAlignment(aln)
        self._initSelectionModels()
        self.res_selection_model.selectionChanged.connect(
            self.signals.resSelectionChanged)
        self.seq_selection_model.selectionChanged.connect(
            self.signals.seqSelectionChanged)
        self._initHomologyCache()
        self._initHomologyCompositeResidues()
        self._initPairwiseConstraints()
        self._initHMLigandConstraints()
        self._initHMProximityConstraints()
        self._expanded_seqs = set()
        self._hidden_seqs = weakref.WeakSet()
        self._hidden_seq_selected_residues = weakref.WeakKeyDictionary()
        self._any_hidden = False
        self._seq_shown_state_cache = None
        self._name_query = ""
        self._filter_enabled = False
    def _initSelectionModels(self):
        """
        Instantiate the residue and sequence selection models.
        """
        self.res_selection_model = ResidueSelectionModel(self)
        self.seq_selection_model = SequenceSelectionModel(self)
        self.ann_selection_model = AnnotationSelectionModel(self)
    def setSeqExpanded(self, seq, expanded=True):
        if expanded == (seq in self._expanded_seqs):
            return
        if expanded:
            self._expanded_seqs.add(seq)
        else:
            self._expanded_seqs.discard(seq)
        self.signals.seqExpansionChanged.emit(seq, expanded)
    def isSeqExpanded(self, seq):
        return seq in self._expanded_seqs
    def _setInnerAlignment(self, aln):
        if self._aln is not None:
            for signal, name in self._aln.signals.allSignalsAndNames():
                signal.disconnect(getattr(self.signals, name))
        self._aln = aln
        for signal, name in self._aln.signals.allSignalsAndNames():
            signal.connect(getattr(self.signals, name))
        self.signals.sequencesAboutToBeRemoved.connect(self._initHomologyCache)
        self.signals.sequencesAboutToBeRemoved.connect(
            self._removeConstraintsForRemovedSequences)
        self.signals.residuesAboutToBeRemoved.connect(
            self._removeConstraintsForRemovedResidues)
        for signal in (self.signals.sequencesAboutToBeRemoved,
                       self.signals.sequencesAboutToBeInserted,
                       self.signals.sequencesAboutToBeReordered):
            signal.connect(self.resetHomologyCompositeResidues)
        for signal in (self.signals.sequenceResiduesChanged,
                       self.signals.residuesAdded,
                       self.signals.residuesAboutToBeRemoved):
            signal.connect(self._residue_outlines.invalidateOutlines)
        for signal in (self.signals.sequencesInserted,
                       self.signals.sequencesRemoved,
                       self.signals.sequencesReordered,
                       self.signals.alignmentCleared):
            signal.connect(self._emitHiddenSeqsChangedOnSeqChange)
    def __deepcopy__(self, memo):
        """
        Copy the alignment.  Note that the isWorkspace() status is
        intentionally not copied.  Since the sequence model (and not the
        alignment itself) is responsible for keeping the workspace alignment
        up to date with the Maestro workspace, a copy of it is not going to
        be kept up to date and therefore does not qualify as a workspace
        alignment.
        """
        copied_inner_aln = copy.deepcopy(self._aln, memo)
        aln = self.__class__(aln=copied_inner_aln)
        def get_new_residues(residues):
            res_idxs = self.getResidueIndices(residues, sort=False)
            return [aln[sidx][ridx] for sidx, ridx in res_idxs]
        new_highlight_res = get_new_residues(self._residue_highlights.keys())
        new_highlights = dict(
            zip(new_highlight_res, self._residue_highlights.values()))
        aln._residue_highlights = new_highlights
        og_outline_map = self._residue_outlines.getOutlineMap()
        new_outline_res = get_new_residues(og_outline_map.keys())
        new_outline_map = dict(zip(new_outline_res, og_outline_map.values()))
        aln._residue_outlines.setResOutlines(new_outline_map)
        res_sel_model = aln.res_selection_model
        sel_residues = get_new_residues(self.res_selection_model.getSelection())
        if sel_residues:
            res_sel_model.setSelectionState(sel_residues,
                                            selected=True,
                                            _undoable=False)
        seq_sel_model = aln.seq_selection_model
        sel_seq_idxs = self.seq_selection_model.getSelectionIndices()
        sel_sequences = [aln._aln[sidx] for sidx in sel_seq_idxs]
        if sel_sequences:
            seq_sel_model.setSelectionState(sel_sequences, selected=True)
        for idx, seq in enumerate(self):
            aln.setSeqExpanded(aln[idx], expanded=self.isSeqExpanded(seq))
        hidden_seqs = [
            aln[idx]
            for idx, shown in enumerate(self.getSeqShownStates())
            if not shown
        ]
        aln._showHideSeqCommand(to_hide=hidden_seqs)
        aln.setUndoStack(self.undo_stack)
        return aln
    def __repr__(self):
        """
        :rtype: str
        :return: A str representation of the alignment
        """
        return self._aln._getRepr(type(self).__qualname__)
    def setUndoStack(self, undo_stack):
        """
        :param undo_stack: The undo stack on which to push commands
        :type undo_stack: schrodinger.application.msv.command.UndoStack
            Set the undo stack on the object
        """
        self.undo_stack = undo_stack
        self.res_selection_model.setUndoStack(undo_stack)
        self.seq_selection_model.setUndoStack(undo_stack)
    @contextlib.contextmanager
    def suspendAnchors(self):
        """
        "Undoable" suspendAnchors. This is necessary for when we use
        suspendAnchors in a macro in order to preserve inter-command state.
        For example, if we do::
            with compress_command(undo_aln.undo_stack):
                with undo_aln.suspendAnchors():
                    undo_aln.removeAllGaps()
                    undo_aln.addGapsByIndices(idxs)
        Without an undoable suspendAnchors the above code will error on undo
        since the anchors won't be resuspended.
        This is implemented by creating a command for both entering and exiting
        the context. If we're not in a macro, this method just delegates to
        the wrapped alignment.
        """
        if not getattr(self.undo_stack, 'in_macro', False):
            with self._aln.suspendAnchors():
                yield
        else:
            self._suspendAnchors()
            yield
            self._unsuspendAnchors()
    @command.do_command
    def _suspendAnchors(self):
        redo = self._aln._suspendAnchors
        undo = self._aln._unsuspendAnchors
        return redo, undo, 'Suspend Anchors'
    @command.do_command
    def _unsuspendAnchors(self):
        redo = self._aln._unsuspendAnchors
        undo = self._aln._suspendAnchors
        return redo, undo, 'Unsuspend Anchors'
    def getSelectedSequences(self):
        """
        Return a list of the currently selected sequences in alignment order.
        :return: List of currently selected sequences
        :rtype: list[sequence.Sequence]
        """
        selected_seqs_set = self.seq_selection_model.getSelection()
        return [seq for seq in self if seq in selected_seqs_set]
    @command.do_command
    def reorderSequences(self, seq_indices):
        """
        Reorder the sequences in the alignment using the specified list of
        indices
        :param seq_indices: A list with the new indices for sequences
        :type: list of int
        :raises ValueError: In the event that the list of indices does not match
            the length of the alignment
        """
        if len(seq_indices) != len(self):
            msg = ("The number of elements in seq_indices should match the "
                   "number of sequences in the alignment")
            raise ValueError(msg)
        redo, undo = self._getReorderSequencesFuncs(seq_indices)
        desc = "Reorder Sequences"
        return redo, undo, desc
    def _getReorderSequencesFuncs(self, seq_indices):
        """
        Generate redo and undo commands for reordering the sequences in the
        alignment using the specified list of indices.
        :param seq_indices: A list with the new indices for sequences
        :type: list of int
        :return: Redo and undo commands
        :rtype: tuple(function, function)
        """
        inverted_indices = self._getUndoSequenceOrdering(seq_indices)
        redo = partial(self._aln.reorderSequences, seq_indices)
        undo = partial(self._aln.reorderSequences, inverted_indices)
        return redo, undo
    @command.do_command
    def sortByProperty(self, seq_prop, reverse=False):
        sort_indices = self._aln._getSortByPropertyIndices(seq_prop,
                                                           reverse=reverse)
        redo, undo = self._getReorderSequencesFuncs(sort_indices)
        desc = "Sort Sequences by Property"
        return redo, undo, desc
    @command.do_command
    def sort(self, *, key, reverse=False):
        sort_indices = self._aln._getSortIndices(key, reverse=reverse)
        redo, undo = self._getReorderSequencesFuncs(sort_indices)
        desc = "Sort Sequences"
        return redo, undo, desc
    @command.do_command
    def moveSelectedSequences(self, after_seq):
        """
        Move all selected sequences in the alignment.
        :param dest_seq: The sequence to place the selected sequences after.
        :type dest_seq: sequence.Sequence
        """
        selected = self.seq_selection_model.getSelection()
        move_indices = self._getMoveSequenceAfterIndices(after_seq, selected)
        inverted_indices = self._getUndoSequenceOrdering(move_indices)
        redo = partial(self._aln.reorderSequences, move_indices)
        undo = partial(self._aln.reorderSequences, inverted_indices)
        return redo, undo, self._MOVE_SELECTED_SEQS_DESC
    @staticmethod
    def _getUndoSequenceOrdering(seq_indices):
        """
        Given a new ordering for sequences in an alignment, return an ordering
        that will restore the original order of sequences.
        Given a an alignment [a, b, c, d, e] an ordering of [3, 1, 4, 2, 0] will
        rearrange the sequences into [d, b, e, c, a]. We need an ordering of
        [4, 1, 3, 0, 2] to restore the original arrangement of [a, b, c, d, e].
        This method is used in undo operations.
        :param seq_indices: A list with the new indices for sequences
        :type: list of int
        :rtype: list of int
        :return: An ordering list that will restore the original arrangement of
                 sequences in the alignment
        """
        new_indices = [None] * len(seq_indices)
        for current_index, new_index in enumerate(seq_indices):
            new_indices[new_index] = current_index
        return new_indices
    @command.do_command
    def setReferenceSeq(self, seq):
        # See alignment.ProteinAlignment for documentation
        self._assertCanSetReferenceSeq()
        redo_ordering = self._getReferenceSeqReordering(seq)
        undo_ordering = self._getUndoSequenceOrdering(redo_ordering)
        redo = partial(self._aln.reorderSequences, redo_ordering)
        undo = partial(self._aln.reorderSequences, undo_ordering)
        return redo, undo, self._setReferenceSeqDesc(seq)
    def _setReferenceSeqDesc(self, seq):
        """
        Generate an undo stack description for setting the reference sequence.
        :param seq: The new reference sequence
        :type seq: sequence.ProteinSequence
        :return: The requested description
        :rtype: str
        """
        return "Set %s as Reference Sequence" % seq.fullname
    def addSeq(self, seq, index=None, replace_selection=False):
        """
        Add a single sequence to the alignment
        :param seq: The sequence to add.
        :type seq: sequence.ProteinSequence
        :param index: The index at which to insert; if None, the sequence is
            appended.
        :type index: int
        :param replace_selection: Whether to select the newly added sequences
            and deselect all other sequences.  If False, selection will not be
            changed.
        :type replace_selection: bool
        """
        self.addSeqs([seq], index, replace_selection)
    @command.do_command
    def addSeqs(self, seqs, index=None, replace_selection=False):
        """
        Add multiple sequences to the alignment
        :param seqs: Sequences to add.
        :type seqs: list[sequence.ProteinSequence]
        :param index: The index at which to insert; if None, seqs are appended.
        :type index: int
        :param replace_selection: Whether to select the newly added sequences
            and deselect all other sequences.  If False, selection will not be
            changed.
        :type replace_selection: bool
        """
        self._assertCanAddSeqs(index)
        redo = partial(self._addSeqs, seqs, index, replace_selection)
        def undo():
            self._aln.removeSeqs(seqs)
            self._expanded_seqs -= set(seqs)
        return redo, undo, self._addSeqsDesc(seqs)
    def _addSeqs(self, seqs, index, replace_selection):
        # See addSeqs above for method documentation
        self._expanded_seqs |= set(seqs)
        self._aln.addSeqs(seqs, index)
        self.signals.syncWsResSelection.emit(seqs)
        if replace_selection:
            self.seq_selection_model.clearSelection()
            self.seq_selection_model.setSelectionState(seqs, True)
    def _addSeqsDesc(self, seqs):
        """
        Generate an undo stack description for adding the given sequences.
        :param seqs: Sequences to add
        :type seqs: list[sequence.ProteinSequence]
        :return: The requested description
        :rtype: str
        """
        return f"Add {len(seqs)} Sequences"
    def removeSeq(self, seq):
        # See alignment.BaseAlignment for documentation
        self.removeSeqs([seq])
    def removeSeqs(self, seqs):
        # See alignment.BaseAlignment for documentation
        if self[0] in seqs:
            self._assertCanRemoveRef()
        self._removeSeqs(seqs)
    @command.do_command
    def _removeSeqs(self, seqs):
        # See alignment.BaseAlignment.removeSeqs for documentation
        idx_map = {self.index(seq): seq for seq in seqs}
        old_anchors = self._aln.getAnchoredResidues()
        old_aln_sets, old_set_id, old_no_set = self._getCurrentAlnSets(seqs)
        old_highlight_color_map = self._getHighlightColorMap()
        old_outline_map = self.getOutlineMap().copy()
        invalidated_known_bonds, invalidated_pred_bonds = \
            self._getInvalidatedBonds(seqs)
        expanded_seqs = self._expanded_seqs.intersection(seqs)
        def redo():
            new_highlight_color_map = self._getHighlightColorMap(copy=False)
            new_highlight_color_map = {
                res: value
                for res, value in new_highlight_color_map.items()
                if res.sequence not in seqs
            }
            self._setResHighlights(new_highlight_color_map)
            new_outline_map = self.getOutlineMap()
            new_outline_map = {
                res: value
                for res, value in new_outline_map.items()
                if res.sequence not in seqs
            }
            self._residue_outlines.setResOutlines(new_outline_map)
            self._aln.removeSeqs(seqs)
        def undo():
            for index in sorted(idx_map):
                self._aln.addSeq(idx_map[index], index)
            self.signals.syncWsResSelection.emit(seqs)
            self._aln.anchorResidues(old_anchors)
            self._restoreAlnSets(old_aln_sets, old_set_id)
            self._setResHighlights(old_highlight_color_map)
            self._residue_outlines.setResOutlines(old_outline_map)
            self._aln._restoreInvalidatedBonds(invalidated_known_bonds,
                                               invalidated_pred_bonds)
            # make sure we haven't inadvertantly collapsed any sequences when we
            # removed bonds
            self._expanded_seqs.update(expanded_seqs)
        return redo, undo, self._removeSeqsDesc(seqs)
    def _removeSeqsDesc(self, seqs):
        """
        Generate an undo stack description for removing the given sequences.
        :param seqs: Sequences to remove
        :type seqs: list[sequence.ProteinSequence]
        :return: The requested description
        :rtype: str
        """
        seqs = list(seqs)
        num_seqs = len(seqs)
        plural_sequences = inflect.engine().plural("Sequence", num_seqs)
        if num_seqs > 3:
            seqs_txt = f"{seqs[0].fullname}...{seqs[-1].fullname}"
        else:
            seqs_txt = ", ".join(seq.fullname for seq in seqs)
        return f"Remove {num_seqs} {plural_sequences} ({seqs_txt})"
    @command.do_command
    def renameSeq(self, seq, new_name):
        """
        Changes the name for a sequence
        :param seq: The sequence to change the name of
        :type seq: schrodinger.protein.sequence.ProteinSequence
        :param new_name: The new name for the sequence
        :type new_name: str
        """
        old_name = seq.name
        redo = partial(self._renameSeq, seq, new_name)
        undo = partial(self._renameSeq, seq, old_name)
        return redo, undo, self._renameSeqDesc(seq)
    def _renameSeq(self, seq, name):
        seq.name = name
    def _renameSeqDesc(self, seq):
        """
        Generate an undo stack description for renaming the given sequence.
        :param seqs: Sequence to rename
        :type seqs: sequence.ProteinSequence
        :return: The requested description
        :rtype: str
        """
        return f'Rename Sequence {seq.fullname}'
    @command.do_command
    def changeSeqChain(self, seq, new_chain):
        """
        Changes the chain for a sequence
        :param seq: The sequence to change the name of
        :type seq: schrodinger.protein.sequence.ProteinSequence
        :param new_chain: The new chain name for the sequence
        :type new_chain: str
        """
        old_chain = seq.chain
        redo = lambda: setattr(seq, "chain", new_chain)
        undo = lambda: setattr(seq, "chain", old_chain)
        return redo, undo, self._renameSeqDesc(seq)
    @command.do_command
    def clear(self):
        # See alignment.ProteinAlignment for documentation
        seqs = list(self._aln)
        def undo():
            self._aln.addSeqs(seqs)
        redo = partial(self._aln.clear)
        return redo, undo, self._CLEAR_DESC
    @command.do_command
    def removeElements(self, elements):
        # See alignment.ProteinAlignment for documentation
        self._aln._assertCanRemove(elements)
        undo = self._createRemoveElementsUndo(elements)
        redo = self._createRemoveElementsRedo(elements)
        desc = f"Remove {len(elements)} Sequence Elements"
        return redo, undo, desc
    def _createRemoveElementsRedo(self, elements):
        residues = [elem for elem in elements if elem.is_res]
        gap_idxs = []
        for elem in elements:
            if elem.is_gap:
                seq_i = self._aln.index(elem.sequence)
                res_i = elem.idx_in_seq
                gap_idxs.append((seq_i, res_i))
        def redo():
            new_highlight_color_map = self._getHighlightColorMap()
            for res in residues:
                new_highlight_color_map.pop(res, None)
            self._setResHighlights(new_highlight_color_map)
            new_outline_map = self.getOutlineMap().copy()
            for elem in elements:
                new_outline_map.pop(elem, None)
            self._residue_outlines.setResOutlines(new_outline_map)
            gaps = []
            for seq_i, res_i in gap_idxs:
                gaps.append(self._aln[seq_i][res_i])
            assert all(elem.is_gap for elem in gaps)
            self._aln.removeElements(residues + gaps)
        return redo
    def _createRemoveElementsUndo(self, elements):
        """
        Helper method to create the undo function for `removeElements`
        """
        orig_indices = self.getResidueIndices(elements)
        elems_to_restore = collections.defaultdict(list)
        for s_idx, r_idx in orig_indices:
            seq = self._aln[s_idx]
            elems_to_restore[seq].append((r_idx, seq[r_idx]))
        has_anchors = len(self.getAnchoredResidues()) > 0
        if has_anchors:
            gaps_to_remove = dict()
            for seq, ele_info in elems_to_restore.items():
                eles = [t[1] for t in ele_info]
                gaps = self._getAnchorConservingGapIdxs(seq, eles)
                gaps_to_remove[seq] = gaps
        selected = self.res_selection_model.getSelection()
        sel_to_remove = selected.intersection(elements)
        og_highlight_color_map = self._getHighlightColorMap()
        old_outline_map = self.getOutlineMap().copy()
        disulfides_to_restore = set()
        pred_disulfides_to_restore = set()
        for ele in elements:
            if ele.is_gap:
                continue
            if ele.disulfide_bond is not None:
                disulfides_to_restore.add(ele.disulfide_bond)
            if ele.pred_disulfide_bond is not None:
                pred_disulfides_to_restore.add(ele.pred_disulfide_bond)
        def undo():
            with self.suspendAnchors():
                for seq, res_list in elems_to_restore.items():
                    if has_anchors:
                        gap_idxs = gaps_to_remove[seq]
                        gaps = [seq[g_idx] for g_idx in gap_idxs]
                        self._aln.removeElements(gaps)
                    for r_idx, res in res_list:
                        self._aln.addElements(seq, r_idx, [res])
            self.res_selection_model.setSelectionState(sel_to_remove,
                                                       True,
                                                       _undoable=False)
            self._setResHighlights(og_highlight_color_map)
            self._residue_outlines.setResOutlines(old_outline_map)
            self._aln._restoreInvalidatedBonds(disulfides_to_restore,
                                               pred_disulfides_to_restore)
        return undo
    @command.do_command
    def mutateResidues(self, seq_i, start, end, elements, *, select=False):
        """
        See `alignment.ProteinAlignment.mutateResidues` for additional method
        documentation.  Note that the `select` argument is specific to this
        class and isn't present in the `alignment.ProteinAlignment` method.
        :param select: Whether to select the mutated residues.  Note that this
            argument applies on undo as well, so it should only be True if the
            residues to be mutated are selected when this method is called.
            Also note that this argument is keyword-only.
        :type select: bool
        """
        self._assertCanMutateResidues(seq_i, start, end, elements)
        seq = self[seq_i]
        to_remove = seq[start:end]
        net_lost_res = to_remove[len(elements):]
        new_gap_idxs = self._getAnchorConservingGapIdxs(seq, net_lost_res)
        num_mutated = end - start
        def redo():
            self.res_selection_model.setSelectionState(to_remove,
                                                       False,
                                                       _undoable=False)
            self._aln.mutateResidues(seq_i, start, end, elements)
            if select:
                last_mutated_idx = start + len(elements)
                mutated_elems = seq[start:last_mutated_idx]
                to_select = set(mutated_elems)
                self.res_selection_model.setSelectionState(to_select,
                                                           True,
                                                           _undoable=False)
        def undo():
            mutated_elems = seq[start:start + len(elements)]
            to_restore = to_remove
            with self.suspendAnchors():
                if len(new_gap_idxs):
                    new_gaps = [seq[idx] for idx in new_gap_idxs]
                    self._aln.removeElements(new_gaps)
                self._aln.removeElements(mutated_elems)
                self._aln.addElements(seq, start, to_restore)
            if select:
                to_select = set(to_restore)
                self.res_selection_model.setSelectionState(to_select,
                                                           True,
                                                           _undoable=False)
        desc = f"Mutate {num_mutated} Residues"
        return redo, undo, desc
    @command.do_command
    def replaceResiduesWithGaps(self, residues):
        # See alignment.ProteinAlignment for documentation
        self._assertCanRemove(residues)
        def redo():
            sel_model = self.res_selection_model
            cur_selection = sel_model.getSelection()
            for (seq_i, res_i), res in zip(self.getResidueIndices(residues),
                                           residues):
                gap = residue.Gap()
                self._aln.mutateResidues(seq_i, res_i, res_i + 1, [gap])
                if res in cur_selection:
                    sel_model.setSelectionState([gap], True, _undoable=False)
        undo_list = []
        for (seq_i, res_i) in self.getResidueIndices(residues):
            undo_list.append((seq_i, res_i, self[seq_i][res_i]))
        def undo():
            sel_model = self.res_selection_model
            cur_selection = sel_model.getSelection()
            for seq_i, res_i, res in undo_list:
                gap = self._aln[seq_i][res_i]
                self._aln.mutateResidues(seq_i, res_i, res_i + 1, [res])
                if gap in cur_selection:
                    sel_model.setSelectionState([res], True, _undoable=False)
        num_residues = len(residues)
        residues_text = inflect.engine().plural("Residue", num_residues)
        desc = f"Replace {num_residues} {residues_text} with Gaps"
        return redo, undo, desc
    @command.do_command
    def addElements(self, seq, res_i, elements, *, select=False):
        """
        See `alignment.ProteinAlignment.addElements` for additional method
        documentation.  Note that the `select` argument is specific to this
        class and isn't present in the `alignment.ProteinAlignment` method.
        :param select: Whether to select the added residues.  Note that this
            argument is keyword-only.
        :type select: bool
        """
        self._assertCanInsert(seq, [res_i])
        def redo():
            self._aln.addElements(seq, res_i, elements)
            if select:
                new_res = seq[res_i:res_i + len(elements)]
                self.res_selection_model.setSelectionState(new_res,
                                                           True,
                                                           _undoable=False)
        def undo():
            to_remove = seq[res_i:res_i + len(elements)]
            self._aln.removeElements(to_remove)
        num_elements = len(elements)
        element_text = inflect.engine().plural("Element", num_elements)
        desc = f"Add {num_elements} Sequence {element_text}"
        return redo, undo, desc
    @command.do_command
    def addDisulfideBond(self, res1, res2, known=True):
        redo = partial(self._aln.addDisulfideBond, res1, res2, known=known)
        # We use a lambda for undo so `res1.disulfide_bond` isn't evaluated
        # until undo is actually called.
        if known:
            undo = lambda: self._aln.removeDisulfideBond(res1.disulfide_bond)
        else:
            undo = lambda: self._aln.removeDisulfideBond(res1.
                                                         pred_disulfide_bond)
        desc = "Add Disulfide Bond"
        return redo, undo, desc
    @command.do_command
    def removeDisulfideBond(self, bond):
        res1, res2 = bond
        known = bond == res1.disulfide_bond
        # We use a lambda for redo so `res1.disulfide_bond` isn't evaluated
        # until redo is actually called.
        if known:
            redo = lambda: self._aln.removeDisulfideBond(res1.disulfide_bond)
        else:
            redo = lambda: self._aln.removeDisulfideBond(res1.
                                                         pred_disulfide_bond)
        undo = partial(self._aln.addDisulfideBond, res1, res2, known=known)
        desc = "Remove Disulfide Bond"
        return redo, undo, desc
    def _getRestoreGapsMethod(self):
        """
        Utility method for creating an undo method that reverts a change to
        gaps.  Note that the undo method restores the same gap *instances* to
        avoid interfering with other undo steps.
        :return: A method that restores the state of gaps to when the method
            was created.
        :rtype: function
        """
        original_gaps = self.getGaps()
        original_gaps_idxs = self._getElementIndexes(original_gaps)
        selection = self.res_selection_model.getSelection()
        sel_gaps = [elem for elem in selection if elem.is_gap]
        sel_gap_idxs = self.getResidueIndices(sel_gaps)
        def undo():
            with self.suspendAnchors():
                self._aln.removeAllGaps()
                for seq, gaps_by_seq in zip(self, original_gaps_idxs):
                    for gap_index, gap in gaps_by_seq:
                        seq.insertElements(gap_index, [gap])
            gaps_to_select = [
                self._aln[res_i][seq_i] for (res_i, seq_i) in sel_gap_idxs
            ]
            self.res_selection_model.setSelectionState(gaps_to_select,
                                                       True,
                                                       _undoable=False)
        return undo
    @command.do_command
    def addGapsByIndices(self, gap_indices):
        # See alignment.ProteinAlignment for documentation
        # validate gap_indices separately so an exception is not buried in a
        # command object
        self._validateGapIndices(gap_indices)
        num_gaps = sum(len(indices) for indices in gap_indices)
        redo = partial(self._aln.addGapsByIndices, gap_indices)
        desc = f"Add {num_gaps} Gaps to the Sequences"
        return redo, self._getRestoreGapsMethod(), desc
    @command.do_command(command_id=command.CommandType.AddGaps,
                        command_class=command.TimeBasedCommand)
    def addGapsBeforeIndices(self, gap_indices):
        # See alignment.ProteinAlignment for documentation
        # validate gap_indices separately so an exception is not buried in a
        # command object
        self._validateGapBeforeIndices(gap_indices)
        num_gaps = sum(len(indices) for indices in gap_indices)
        redo = partial(self._aln.addGapsBeforeIndices, gap_indices)
        desc = f"Add {num_gaps} Gaps to the Sequences"
        def merge_desc(desc1, desc2):
            bad_prefix = desc1[:4] != desc2[:4] or desc1[:4] != "Add "
            bad_suffix = desc1[-22:] != desc2[-22:] or desc1[
                -22:] != " gaps to the sequences"
            if bad_prefix or bad_suffix:
                # Check that descriptions are "Add _ gaps to the sequences"
                # and return the first description if not.
                return desc1
            # Add the two numbers of gaps together.
            num_gaps = int(desc1[4:-22]) + int(desc2[4:-22])
            return "Add %i Gaps to the Sequences" % num_gaps
        return redo, self._getRestoreGapsMethod(), desc, merge_desc
    @command.do_command
    def padAlignment(self):
        # See alignment.ProteinAlignment for documentation
        redo = self._aln.padAlignment
        desc = "Pad Alignment"
        return redo, self._getRestoreGapsMethod(), desc
    @command.do_command
    def removeTerminalGaps(self):
        # See alignment.ProteinAlignment for documentation
        terminal_gaps = self.getTerminalGaps()
        def redo():
            to_remove_by_seq = self.getTerminalGaps()
            to_remove = list(itertools.chain(*to_remove_by_seq))
            self._aln.removeElements(to_remove)
        def undo():
            for seq, gaps in zip(self, terminal_gaps):
                if gaps:
                    seq.extend(gaps)
        desc = "Remove Terminal Gaps from Sequences"
        return redo, undo, desc
    def _getElementIndexes(self, elements):
        """
        Returns the indices (in the alignment) of the specified residues
        :param elements: Residues and gaps to get indices for, formatted as
            [list of residues/gaps for sequence 0, list of residues/gaps for
            sequence 1,...]
        :type elements: list[list[residue.AbstractSequenceElement]]
        :rtype: List of the requested indices and elements, formatted as
            [list of (index, element) tuples for sequence 0,
            list of (index, element) tuples for sequence 1, ...].
            Tuples are given in the order they appear in the sequence.
        :return: list[list[tuple(int, residue.AbstractSequenceElement)]]
        """
        indexes = []
        for eles in elements:
            cur_indexes = [(ele.idx_in_seq, ele) for ele in eles]
            cur_indexes.sort()
            indexes.append(cur_indexes)
        return indexes
    @command.do_command
    def removeAllGaps(self):
        # See alignment.ProteinAlignment for documentation
        desc = "Remove All Gaps From Sequences"
        redo = partial(self._aln.removeAllGaps)
        return redo, self._getRestoreGapsMethod(), desc
    @command.do_command
    def insertSubalignment(self, aln, start):
        # See parent class for documentation
        self._aln._assertRectangular(aln)
        end = start + aln.num_columns
        redo = partial(self._aln.insertSubalignment, aln, start)
        undo = partial(self._aln.removeSubalignment, start, end)
        desc = f"Insert Section in Sequences (at Position {start + 1})"
        return redo, undo, desc
    def _getUndoSubalignment(self, start, end):
        """
        Return a new alignment containing new sequences with identical residues
        The returned alignment must only be used for undo operations.
        """
        AlignmentClass = self._aln.__class__
        if len(self._aln) == 0:
            return AlignmentClass()
        SequenceClass = self._aln[0].__class__
        seqs = []
        for orig_seq in self._aln:
            new_seq = SequenceClass()
            # Bypass sequence __init__ to avoid re-parenting residues
            new_seq._sequence = orig_seq[start:end]
            seqs.append(new_seq)
        return AlignmentClass(seqs)
    @command.do_command
    def removeSubalignment(self, start, end):
        # See alignment.ProteinAlignment for documentation
        self._aln._assertCanRemoveSubalignment(start, end)
        subalignment = self._getUndoSubalignment(start, end)
        redo = partial(self._aln.removeSubalignment, start, end)
        def undo():
            with self.suspendAnchors():
                self._aln._insertSubalignment(subalignment,
                                              start,
                                              require_rectangular=False)
        desc = f"Remove Section ({start + 1}-{end}) of Sequences"
        return redo, undo, desc
    @command.do_command
    def replaceSubalignment(self, aln, start, end):
        # See alignment.ProteinAlignment for documentation
        self._aln._assertRectangular(aln)
        original_subaln = self._getUndoSubalignment(start, end)
        undo_end = start + aln.num_columns
        redo = partial(self._aln.replaceSubalignment, aln, start, end)
        undo = partial(self._aln.replaceSubalignment, original_subaln, start,
                       undo_end)
        desc = "Replace Section ({start + 1}-{end}) in Sequences"
        return redo, undo, desc
    @command.do_command
    def minimizeAlignment(self):
        # See alignment.ProteinAlignment for documentation
        def redo():
            with self.suspendAnchors():
                self._aln.minimizeAlignment()
        gap_only_columns = self.getGapOnlyColumns()
        def undo():
            with self.suspendAnchors():
                self._aln.addGapsByIndices(gap_only_columns)
        desc = "Remove Gap-Only Columns"
        return redo, undo, desc
    @command.do_command
    def anchorResidues(self, residues):
        self._aln._assertCanAnchor(residues)
        old_anchors = self._aln.getAnchoredResidues()
        redo = partial(self._aln.anchorResidues, residues)
        def undo():
            self._aln.clearAnchors()
            self._aln.anchorResidues(old_anchors)
        desc = f'Anchor {len(residues)} Residues'
        return redo, undo, desc
    @command.do_command
    def removeAnchors(self, residues):
        old_anchors = self._aln.getAnchoredResidues()
        redo = partial(self._aln.removeAnchors, residues)
        undo = partial(self._aln.anchorResidues, old_anchors)
        desc = f'Unanchor {len(residues)} Residues'
        return redo, undo, desc
    @command.do_command
    def clearAnchors(self):
        old_anchors = self._aln.getAnchoredResidues()
        redo = self._aln.clearAnchors
        undo = partial(self._aln.anchorResidues, old_anchors)
        desc = 'Clear Residue Anchors'
        return redo, undo, desc
    def _anchorSelectionValid(self):
        """
        Helper method for determining whether anchoring the selection is valid.
        Anchoring is valid if there exists at least one selected residue for
        which it's valid.
        :return: Whether anchoring the selection is valid.
        :rtype: bool
        """
        ref_seq = self.getReferenceSeq()
        ref_len = len(ref_seq)
        sel_residues = self.res_selection_model.getSelection()
        cols_with_ref_selected = set()
        cols_with_non_ref_selected = set()
        for res in sel_residues:
            is_gap = res.is_gap
            col_idx = res.idx_in_seq
            if res.sequence is ref_seq:
                if not is_gap:
                    cols_with_ref_selected.add(col_idx)
                continue
            elif (not is_gap and col_idx < ref_len and
                  not ref_seq[col_idx].is_gap):
                return True
            cols_with_non_ref_selected.add(col_idx)
        cols_with_only_ref_selected = (cols_with_ref_selected -
                                       cols_with_non_ref_selected)
        for col in cols_with_only_ref_selected:
            # we didn't add any columns with reference gaps to
            # cols_with_ref_selected, so we don't need to check that here
            if any(col_idx < len(seq) and not seq[col_idx].is_gap
                   for seq in itertools.islice(self, 1, None)):
                return True
        return False
    def anchorResidueValid(self, res):
        """
        Helper method returning whether anchoring the given residue is valid.
        Anchoring is valid if the given residue isn't a gap and isn't aligned
        to a reference gap.
        If the given residue is a reference residue, this method will return
        whether at least one residue aligned to it can be anchored.
        :param res: The given residue.
        :type res: residue.Residue
        :return: Whether the given residue can be anchored.
        :rtype: bool
        """
        ref_seq = self.getReferenceSeq()
        res_idx = res.idx_in_seq
        if res in ref_seq:
            col = self._aln.getColumn(res_idx)
            return not (len(col) == 1 or res.is_gap or
                        all(r is None or r.is_gap for r in col))
        else:
            return not (res.is_gap or res_idx >= len(ref_seq) or
                        ref_seq[res_idx].is_gap)
    @command.do_command
    def setSelectedResColor(self, color):
        """
        Set the selected residues to the specified color
        :param color: RGB tuple to color the residues or empty tuple to clear
        :type  color: tuple
        """
        sel_res = self.res_selection_model.getSelection()
        redo, undo = self._getHighlightCommands(sel_res, color)
        if color:
            color_name = QtGui.QColor(*color).name()
            desc = f"Apply Color {color_name} to"
        else:
            desc = "Remove Highlight from"
        n_res = len(sel_res)
        residue_text = inflect.engine().plural("Residue", n_res)
        desc += f" {n_res} Selected {residue_text}"
        return redo, undo, desc
    @command.do_command
    def setResidueHighlight(self, residues, color):
        """
        Set the specified residues to the specified color
        :param color: RGB tuple to color the residues or empty tuple to clear
        :type  color: tuple
        """
        redo, undo = self._getHighlightCommands(residues, color)
        desc = "Pick" if color else "Unpick"
        n_res = len(residues)
        residue_text = inflect.engine().plural("Residue", n_res)
        desc += f" {n_res} {residue_text}"
        return redo, undo, desc
    @command.do_command
    def clearAllHighlights(self):
        """
        Clear all residue highlights
        """
        orig_color_map = self._getHighlightColorMap()
        orig_outline_map = self.getOutlineMap().copy()
        def redo():
            self._setResHighlights({})
            self._residue_outlines.setResOutlines({})
        def undo():
            self._setResHighlights(orig_color_map)
            self._residue_outlines.setResOutlines(orig_outline_map)
        desc = "Clear All Residue Highlights"
        return redo, undo, desc
    def getHighlightColorMap(self):
        """
        :return: Read-only mapping between residue object and RGB tuple
        :rtype: types.MappingProxy
        """
        color_map = self._residue_highlights
        return types.MappingProxyType(color_map)
    def _getHighlightColorMap(self, copy=True):
        """
        :param copy: Whether to copy the map
        :type  copy: bool
        :return: Mapping mapping between residue object and RGB tuple
        :rtype: dict
        """
        color_map = self._residue_highlights
        if copy:
            color_map = color_map.copy()
        return color_map
    def duplicateSeqs(self,
                      seqs_map,
                      index=None,
                      replace_selection=False,
                      source_aln=None):
        """
        Copies the existing sequences in this alignment to the bottom
        of this alignment.
        :param seqs_map: Dictionary of the new sequence copies mapped ot their
            source sequence
        :type  seqs_map: dict
        :param index: The index at which to insert; if None, seqs are appended.
            Must be None if adding single-chain sequences.
        :type index: int
        :param replace_selection: If the selection should be replaced with the new
            sequences
        :type  replace_selection: bool
        :param source_aln: Alignment to get the color map data, None if
            this alignment is the source
        :type  source_aln: _ProteinAlignment or None
        """
        seqs = list(seqs_map.keys())
        if index == 0:
            raise RuntimeError("Cannot import sequence as the reference.")
        self.addSeqs(seqs, index=index, replace_selection=replace_selection)
        self.duplicateSeqsHighlightColorMap(seqs_map, source_aln=source_aln)
    @command.do_command
    def duplicateSeqsHighlightColorMap(self, seqs_map, source_aln=None):
        """
        Copies the color map highlighting of the original sequence onto the new
        sequence.
        :param seqs_map: Dictionary of new sequence copies mapped to their source
                 sequence
        :type  seqs_map: dict
        :param source_aln: Alignment to get the color map data, None if
            this alignment is the source
        :type  source_aln: _ProteinAlignment or None
        """
        orig_color_map = self._getHighlightColorMap()
        new_color_map = self._getHighlightColorMap(copy=False)
        if source_aln:
            source_color_map = source_aln._getHighlightColorMap()
        else:
            source_color_map = orig_color_map
        for seq, orig_seq in seqs_map.items():
            for res, orig_res in zip(seq, orig_seq):
                color = source_color_map.get(orig_res)
                if color is not None:
                    new_color_map[res] = color
        redo = partial(self._setResHighlights, new_color_map)
        undo = partial(self._setResHighlights, orig_color_map)
        plural_sequences = inflect.engine().plural("Sequence", len(seqs_map))
        desc = f"Duplicate {len(seqs_map)} {plural_sequences}"
        return redo, undo, desc
    def _getHighlightCommands(self, residues, color):
        orig_color_map = self._getHighlightColorMap()
        new_color_map = self._getHighlightColorMap(copy=False)
        for res in residues:
            if not res.is_res:
                # Can't highlight gaps
                continue
            if color:
                new_color_map[res] = color
            else:
                new_color_map.pop(res, None)
        redo = partial(self._setResHighlights, new_color_map)
        undo = partial(self._setResHighlights, orig_color_map)
        return redo, undo
    def _setResHighlights(self, color_map):
        self._residue_highlights = color_map
        self.signals.resHighlightStatusChanged.emit(bool(color_map))
    def getResOutlinesForSeq(self, seq):
        """
        Get the residue outline blocks for the given sequence
        """
        return self._residue_outlines.getResOutlinesForSeq(seq)
    def getOutlineMap(self):
        """
        Get the read-only map of outlines
        """
        return self._residue_outlines.getOutlineMap()
    @command.do_command
    def setSelectedResOutlineColor(self, color):
        sel_res = self.res_selection_model.getSelection()
        return self._residue_outlines._getResOutlineColorCmd(sel_res, color)
    def isWorkspace(self):
        """
        :return: Whether this alignment is controlled by the structure model and
            only includes sequences that are currently included in the
            workspace.
        :rtype: bool
        """
        return self._is_workspace
    def insertGapsToLeftOfSelection(self):
        """
        Insert one gap to the left of every selected block of residues/gaps.
        (I.e., if three contiguous residues are selected, only one gap will be
        inserted, and it will be placed to the left of the first selected
        residue.)
        :raises AnchoredResidueError: if inserting gaps would break anchors
        """
        aln_sel_indices = [[] for _ in self]
        for seq_i, res_i in self.res_selection_model.getSelectionIndices():
            aln_sel_indices[seq_i].append(res_i)
        aln_gaps_to_add = []
        for seq_sel_indices in aln_sel_indices:
            seq_sel_indices = sorted(seq_sel_indices)
            seq_gaps_to_add = []
            prev_sel_index = -2
            for cur_sel_index in seq_sel_indices:
                if cur_sel_index - 1 != prev_sel_index:
                    seq_gaps_to_add.append(cur_sel_index)
                prev_sel_index = cur_sel_index
            aln_gaps_to_add.append(seq_gaps_to_add)
        self.addGapsBeforeIndices(aln_gaps_to_add)
    def deselectGaps(self):
        """
        Deselect currently selected gaps
        """
        res_selection_model = self.res_selection_model
        sel_residues = res_selection_model.getSelection()
        to_deselect = {elem for elem in sel_residues if elem.is_gap}
        with command.compress_command(self.undo_stack, "Deselect All Gaps"):
            res_selection_model.setSelectionState(to_deselect, False)
    def expandSelectionAlongSequences(self, between_gaps):
        """
        Expand selected gaps along sequences, either expanding
        selected residues to fill between gaps, or expanding
        selected gaps to fill along gaps.
        :param between_gaps: Whether to expand the selection between or along
                             gaps. True for between gaps, False for along gaps.
        :type between_gaps: bool
        """
        res_selection_model = self.res_selection_model
        sel_residues = res_selection_model.getSelection()
        new_selection = set()
        for seq in {res.sequence for res in sel_residues}:
            last_div = -1
            contains_selected = False
            for idx, res in enumerate(seq):
                if res.is_gap == between_gaps:
                    if contains_selected:
                        new_selection.update(seq[last_div + 1:idx])
                    last_div = idx
                    contains_selected = False
                elif res in sel_residues:
                    contains_selected = True
            if contains_selected:
                new_selection.update(seq[last_div + 1:])
        with command.compress_command(self.undo_stack,
                                      "Expand Selection Along Sequences"):
            res_selection_model.setSelectionState(new_selection, True)
    def expandSelectionAlongColumns(self):
        """
        Expand selection along the columns of selected elements.
        """
        res_selection_model = self.res_selection_model
        sel_residues = res_selection_model.getSelection()
        new_selection = set()
        for col in self.columns():
            if sel_residues.intersection(col):
                new_selection.update(col)
        with command.compress_command(self.undo_stack,
                                      "Expand Selection Along Columns"):
            res_selection_model.setSelectionState(new_selection, True)
    def expandSelectionFromReference(self):
        """
        Expand selection along columns of selected reference elements
        """
        res_selection_model = self.res_selection_model
        sel_residues = res_selection_model.getSelection()
        new_selection = set()
        for col in self.columns():
            if col[0] in sel_residues:
                new_selection.update(col)
        with command.compress_command(self.undo_stack,
                                      "Expand Selection From Reference"):
            res_selection_model.setSelectionState(new_selection, True)
    def expandSelectionToFullChain(self):
        """
        Select all the residues in any sequence in which there is an already
        selected residue
        """
        sel_residues = self.res_selection_model.getSelection()
        sequences = (set(res.sequence) for res in sel_residues)
        to_select = set.union(*sequences)
        with command.compress_command(self.undo_stack,
                                      self._EXPAND_SELECTION_TO_FULL_CHAIN):
            self.res_selection_model.setSelectionState(to_select, True)
    def expandSelectionToAnnotationValues(self,
                                          anno,
                                          ann_index=0,
                                          cdr_scheme=None):
        """
        Expand the selection to other residues with the same annotation values.
        See `ProteinSequence.getAnnotationValueForComparison` for details of
        what "the same value" means.
        :param anno: Protein sequence annotation enum member. If
            anno.can_expand is False, this method will be a no-op.
        :type anno: annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES
        :param ann_index: Annotation index for multi-value annotations
        :type ann_index: int
        :param cdr_scheme: CDR scheme for antibody annotation
        :type cdr_scheme: annotation.AntibodyCDRScheme
        """
        if not anno.can_expand:
            return
        def get_anno_val(seq, col):
            return seq.getAnnotationValueForComparison(col, anno, ann_index,
                                                       cdr_scheme)
        res_selection_model = self.res_selection_model
        sel_residues = res_selection_model.getSelection()
        anno_values = set()
        for elem in sel_residues:
            if elem.is_gap:
                continue
            seq = elem.sequence
            anno_val = get_anno_val(seq, elem.idx_in_seq)
            if anno_val is not None:
                anno_values.add(anno_val)
        new_selection = set()
        for seq in self:
            prev_idx = None
            prev_anno = None
            for col, elem in enumerate(seq):
                if elem.is_gap:
                    continue
                anno_val = get_anno_val(seq, col)
                if anno_val is not None and anno_val in anno_values:
                    new_selection.add(elem)
                    if anno_val == prev_anno and col - prev_idx > 1:
                        # Also select gaps inside the annotation
                        new_selection.update(seq[prev_idx + 1:col])
                prev_idx = col
                prev_anno = anno_val
        new_selection -= sel_residues
        if not new_selection:
            return
        with command.compress_command(
                self.undo_stack,
                f"Expand selection to same {anno.title} values"):
            res_selection_model.setSelectionState(new_selection, True)
    def setResSelectionStateForSelectedSeqs(self, selected: bool):
        """
        Set the selection state for all residues in selected sequences
        """
        sel_seqs = self.getSelectedSequences()
        if not sel_seqs:
            return
        residues = itertools.chain(*sel_seqs)
        select = "Select" if selected else "Deselect"
        with command.compress_command(
                self.undo_stack, f"{select} Residues for Selected Sequences"):
            self.res_selection_model.setSelectionState(residues, selected)
    def selectResiduesWithStructure(self):
        """
        Selects all residues with structure
        """
        sel_res_list = self.getResiduesWithStructure()
        res_selection_model = self.res_selection_model
        with command.compress_command(self.undo_stack,
                                      "Select Residues with Structure"):
            res_selection_model.clearSelection()
            res_selection_model.setSelectionState(sel_res_list, True)
    def selectColumns(self, cols, clear=False):
        """
        Select residues in the specified columns of the alignment
        :param cols: Columns to be selected
        :type cols: list(int)
        """
        aln = itertools.zip_longest(*self)
        res_sel = itertools.chain(*[c for i, c in enumerate(aln) if i in cols])
        res_sel = [r for r in res_sel if r is not None]
        with command.compress_command(self.undo_stack, "Select Columns"):
            if clear:
                self.res_selection_model.clearSelection()
            if res_sel:
                self.res_selection_model.setSelectionState(res_sel, True)
    def selectAntibodyCDR(self, scheme):
        """
        Select residues with Antibody CDR.
        :param scheme: Antibody CDR scheme to use
        :type scheme: `schrodinger.protein.annotation.AntibodyCDRScheme`
        """
        self.res_selection_model.clearSelection()
        sel_res = []
        for seq in self:
            for idx, res in enumerate(seq):
                if res and not res.is_gap:
                    if seq.annotations.getAntibodyCDR(
                            idx,
                            scheme).label != annotation.AntibodyCDRLabel.NotCDR:
                        sel_res.append(res)
        self.res_selection_model.setSelectionState(sel_res, True)
    def selectBindingSites(self):
        """
        Select residues with binding site contacts.
        """
        self.res_selection_model.clearSelection()
        sel_res = []
        NC = annotation.BINDING_SITE.NoContact
        for seq in self:
            if seq.getStructure() is None:
                continue
            bsa = seq.annotations.binding_sites
            for idx, res in enumerate(seq):
                if any(b != NC for b in bsa[idx]):
                    sel_res.append(res)
        if sel_res:
            self.res_selection_model.setSelectionState(sel_res, True)
    def selectBindingSitesForLigand(self, ligand, entry_id):
        """
        Select the binding site residues of the ligand across all the sequences
        of the protein.
        :param ligand: Ligand name.
        :type ligand: str
        :param entry_id: Entry id of the protein-ligand complex.
        :type entry_id: int
        """
        residues = []
        for seq in self:
            if seq.entry_id != entry_id:
                continue
            binding_site_res_for_lig = seq.annotations.binding_site_residues.get(
                ligand)
            if binding_site_res_for_lig:
                residues.extend(binding_site_res_for_lig)
        undo_desc = f"Select binding site residues for {ligand}"
        with command.compress_command(self.undo_stack, undo_desc):
            self.res_selection_model.clearSelection()
            self.res_selection_model.setSelectionState(residues, True)
    def selectColsWithStructure(self):
        """
        Select all columns that contain only structured residues
        """
        aln = itertools.zip_longest(*self)
        cols = [
            i for i, c in enumerate(aln)
            if any(r and r.hasStructure() for r in c)
        ]
        self.selectColumns(cols, clear=True)
    def selectIdentityColumns(self):
        """
        Select all identity columns in the alignment
        """
        cols = []
        if self.getReferenceSeq():
            for column_idx, column in enumerate(self.columns()):
                # columns returns None for implicit terminal gaps
                fmt_column_set = {
                    res.short_code if res.is_res else None for res in column
                }
                # The column is identical if it contains exactly one non-gap code
                identical = len(
                    fmt_column_set) == 1 and None not in fmt_column_set
                if identical:
                    cols.append(column_idx)
        self.selectColumns(cols, clear=True)
    def selectAlignedResidues(self):
        """
        Selects residues in columns containing no gaps.
        """
        self.res_selection_model.clearSelection()
        aln = itertools.zip_longest(*self)
        sel_res = []
        for col in aln:
            ref_res = col[0]
            if not ref_res or ref_res.is_gap:
                continue
            col_res = (r for r in col if r is not None and not r.is_gap)
            aln_res = [r for r in col_res if r.short_code == ref_res.short_code]
            if len(aln_res) > 1:
                # More than just the reference seq is aligned.
                sel_res.extend(aln_res)
        if sel_res:
            self.res_selection_model.setSelectionState(sel_res, True)
    def setSecondaryStructureSelectionState(self, res, select=True):
        """
        Set the selection state of residues in the same secondary structure
        as a specified residue.
        :param res: Residue to update the related secondary structure of
        :type res: residue.Residue
        :param select: If True, select the related secondary structure.
                       Otherwise deselect it.
        :type select: bool
        """
        if res.secondary_structure in (None, structure.SS_NONE):
            return
        else:
            seq = res.sequence
            ss = seq.secondary_structures
            for (start, end), _ in ss:
                if start <= res.idx_in_seq <= end:
                    self.res_selection_model.setSelectionState(
                        seq[start:end + 1], select)
                    break
    def setRunSelectionState(self, res, select, expand_cols=False):
        """
        Select or deselect a run of contiguous residues or gaps from a sequence
        :param res: Residue or gap element to select the contiguous run for.
        :type res: residue.Residue
        :param select: Whether to select the run. If False, the run will be deselected.
        :type select: bool
        :param expand_cols: Whether to expand the selection along columns of
                            the alignment.
        :type expand_cols: bool
        """
        if res is None:
            return
        seq = res.sequence
        idx_to_sel = seq.getRun(res)
        if expand_cols:
            res_to_sel = itertools.chain(
                *[self.getColumn(i) for i in idx_to_sel])
        else:
            res_to_sel = [seq[i] for i in idx_to_sel]
        self.res_selection_model.setSelectionState(res_to_sel, select)
    def invertResSelection(self):
        """
        Invert the selection
        """
        res_selection_model = self.res_selection_model
        all_elements = itertools.chain(*self)
        sel_residues = res_selection_model.getSelection()
        with command.compress_command(self.undo_stack,
                                      "Invert Residue Selection"):
            res_selection_model.setSelectionState(all_elements, True)
            res_selection_model.setSelectionState(sel_residues, False)
    def deleteSelectedGaps(self):
        """
        Delete all selected gaps.  Selected residues will remain unchanged.
        """
        selection = self.res_selection_model.getSelection()
        res_to_del = [res_elem for res_elem in selection if res_elem.is_gap]
        self.removeElements(res_to_del)
    def deleteSelection(self):
        """
        Delete all selected residues and gaps.
        """
        selection = self.res_selection_model.getSelection()
        self.removeElements(selection)
    def expandSelectionToRectangle(self):
        """
        When a single block of residues is selected, expand that selection to a
        rectangle.  I.e., if any residue in a column is selected, then select
        that column in any sequence that has any residues selected.
        """
        selection = self.res_selection_model.getSelection()
        seqs = {res.sequence for res in selection}
        indices = {res.idx_in_seq for res in selection}
        to_select = {seq[i] for seq in seqs for i in indices if len(seq) > i}
        self.res_selection_model.setSelectionState(to_select, True)
    def moveSelectionToLeft(self, num_cols):
        """
        Move the selection to the left along existing gaps.  Note that this
        method assumes that there is a single rectangular block of selected
        residues.
        :param num_cols: How many columns should we try to move the selection.
        :type num_cols: int
        :return: How many columns was the selection moved.  Will always be less
            than or equal to `num_cols`.  If less than `num_cols`, it means that
            the selection couldn't be moved `num_cols` columns because there
            weren't enough existing gaps or because there were anchored
            residues.
        :rtype: int
        """
        selection = self.res_selection_model.getSelection()
        if selection & self.getAnchoredResiduesWithRef():
            # We can't move anchored residues
            return 0
        seqs = {res.sequence for res in selection}
        indices = {res.idx_in_seq for res in selection}
        prior_col = min(indices) - 1
        moves_possible = 0
        gap_cols_to_remove = []
        for col in range(prior_col, max(prior_col - num_cols, -1), -1):
            col_res = [cur_seq[col] for cur_seq in seqs]
            if all(res.is_gap for res in col_res):
                gap_cols_to_remove.append(col)
                moves_possible += 1
            else:
                break
        if not moves_possible:
            return 0
        last_new_gap_col = max(indices)
        new_gap_cols = list(
            range(last_new_gap_col - moves_possible + 1, last_new_gap_col + 1))
        self._moveSelectionCommand(seqs, gap_cols_to_remove, new_gap_cols,
                                   moves_possible, "Left")
        return moves_possible
    def moveSelectionToRight(self, num_cols):
        """
        Move the selection to the right along existing gaps or, if necessary, by
        inserting new gaps.  Note that this method assumes that there is a
        single rectangular block of selected residues.
        :param num_cols: How many columns should we try to move the selection.
        :type num_cols: int
        :return: How many columns was the selection moved.  Will always be less
            than or equal to `num_cols`.  If less than `num_cols`, it means that
            the selection couldn't be moved `num_cols` columns due to anchored
            residues.
        :rtype: int
        """
        selection = self.res_selection_model.getSelection()
        anchored = self.getAnchoredResiduesWithRef()
        if selection & anchored:
            # We can't move anchored residues
            return 0
        seqs = {res.sequence for res in selection}
        indices = {res.idx_in_seq for res in selection}
        next_col = max(indices) + 1
        gap_cols_to_remove = []
        num_existing_gap_cols = 0
        for col in range(next_col, next_col + num_cols):
            col_res = [
                cur_seq[col]
                for cur_seq in seqs
                if col < len(cur_seq) and cur_seq[col] is not None
            ]
            if col_res and all(res.is_gap for res in col_res):
                gap_cols_to_remove.append(col)
                num_existing_gap_cols += 1
            else:
                break
        first_new_gap_col = min(indices)
        if num_existing_gap_cols < num_cols:
            # We're not moving entirely along existing gaps, so make sure that
            # the move won't disrupt any anchoring.
            downstream_anchors = {
                res for res in anchored
                if res.sequence in seqs and res.idx_in_seq >= first_new_gap_col
            }
            if downstream_anchors:
                if num_existing_gap_cols == 0:
                    # We can't move the selection at all.  We return early so
                    # that we don't add anything to the undo stack.
                    return 0
                num_cols = num_existing_gap_cols
        new_gap_cols = list(
            range(first_new_gap_col, first_new_gap_col + num_cols))
        self._moveSelectionCommand(seqs, gap_cols_to_remove, new_gap_cols,
                                   num_cols, "Right")
        return num_cols
    @command.do_command
    def _moveSelectionCommand(self, seqs, gap_cols_to_remove, new_gap_cols,
                              num_cols, direction):
        """
        Create and run an undoable command to move the selection left or right.
        :param seqs: The sequences to modify.
        :type seqs: list(schrodinger.protein.sequence.Sequence)
        :param gap_cols_to_remove: The indices of columns where gaps should be
            removed.
        :type gap_cols_to_remove: list(int)
        :param new_gap_cols: The indices of columns where gaps should be added.
            Must be sorted.
        :type new_gap_cols: list(int)
        :param num_cols: The number of columns that the selection is being moved
            by.  Only used to describe the command.
        :type num_cols: int
        :param direction: The direction that the selection is being moved in.
            Only used to describe the command.
        :type direction: str
        """
        def redo():
            with self.suspendAnchors():
                elements_to_remove = []
                gaps_to_insert = [[] for _ in self]
                for cur_seq in seqs:
                    if gap_cols_to_remove:
                        elements_to_remove.extend(cur_seq[i]
                                                  for i in gap_cols_to_remove
                                                  if i < len(cur_seq))
                    gaps_to_insert[self.index(cur_seq)] = [
                        col for i, col in enumerate(new_gap_cols)
                        if col < len(cur_seq) + i
                    ]
                self._aln.removeElements(elements_to_remove)
                self._aln.addGapsByIndices(gaps_to_insert)
        def undo():
            with self.suspendAnchors():
                elements_to_remove = []
                gaps_to_insert = [[] for _ in self]
                for cur_seq in seqs:
                    elements_to_remove.extend(
                        cur_seq[i] for i in new_gap_cols if i < len(cur_seq))
                    if gap_cols_to_remove:
                        gaps_to_insert[self.index(cur_seq)] = gap_cols_to_remove
                self._aln.removeElements(elements_to_remove)
                self._aln.addGapsByIndices(gaps_to_insert)
        description = f"Move Selection {num_cols} Columns to the {direction}"
        return redo, undo, description
    def hideSelectedSeqs(self):
        """
        Hide the selected sequences. If the reference seq is selected, it will
        not be hidden.
        """
        if not self.seq_selection_model.hasSelection():
            return
        sel_seqs = self.seq_selection_model.getSelection()
        self._showHideSeqCommand(to_hide=sel_seqs,
                                 desc="Hide Selected Sequences")
    def showAllSeqs(self):
        """
        Show all currently hidden sequences.
        """
        self._showHideSeqCommand(to_show=self._hidden_seqs,
                                 desc="Show All Sequences")
    def showSeqs(self, sequences, hide_others=False):
        """
        Show the specified sequences, optionally hiding others.
        :param sequences: Sequences to show
        :type sequences: set
        :param hide_others: Whether to hide the other sequences (will not hide
            reference seq). This option is ignored if `sequences` is empty.
        :type hide_others: bool
        """
        if not sequences:
            return
        sequences = set(sequences)
        desc = "Show Sequences"
        if hide_others:
            to_hide = set(self).difference(sequences)
            desc = f"{desc} and Hide Others"
        else:
            to_hide = set()
        self._showHideSeqCommand(sequences, to_hide, desc=desc)
    @command.do_command
    def _showHideSeqCommand(self, to_show=(), to_hide=(), desc=None):
        """
        Commands for hiding/showing sequences.
        The reference seq cannot be hidden.
        """
        to_show = set(to_show)
        # Don't re-show seqs that are already shown
        to_show.difference_update(self.getShownSeqs())
        to_hide = set(to_hide)
        # Don't allow hiding the reference seq
        to_hide.discard(self.getReferenceSeq())
        if not to_show and not to_hide:
            return command.NO_COMMAND
        if desc is None:
            desc_parts = []
            if to_hide:
                desc_parts.append("Hide {len(to_hide)}")
            if to_show:
                desc_parts.append("Show {len(to_show)}")
            # e.g. "Hide 5 and Show 6 Sequences"
            desc = " and ".join(desc_parts) + " Sequences"
        def redo():
            self._setSeqsHiddenState(to_hide)
            self._setSeqsHiddenState(to_show, hidden=False)
            # Need to emit signal before selecting because seq selection model
            # doesn't allow selecting hidden seqs
            self._emitHiddenSeqsChanged()
            self._setHiddenSeqsSelectedState(to_hide)
            self._setHiddenSeqsSelectedState(to_show, hidden=False)
        def undo():
            self._setSeqsHiddenState(to_hide, hidden=False)
            self._setSeqsHiddenState(to_show)
            # Need to emit signal before selecting because seq selection model
            # doesn't allow selecting hidden seqs
            self._emitHiddenSeqsChanged()
            self._setHiddenSeqsSelectedState(to_hide, hidden=False)
            self._setHiddenSeqsSelectedState(to_show)
        return redo, undo, desc
    @command.from_command_only
    def _setSeqsHiddenState(self, sequences, hidden=True):
        """
        Command implementation for hiding/showing sequences.
        Calling code is responsible for removing any sequences that should never
        be hidden (e.g. reference seq)
        """
        if not sequences:
            return
        if hidden:
            self._hidden_seqs.update(sequences)
        else:
            self._hidden_seqs -= sequences
    @command.from_command_only
    def _setHiddenSeqsSelectedState(self, sequences, hidden=True):
        """
        Command implementation for updating selection state for hiding/showing
        sequences
        """
        if not sequences:
            return
        residues_to_toggle = set()
        if hidden:
            # Find and cache residues to deselect
            for res in self.res_selection_model.getSelection():
                seq = res.sequence
                if seq in sequences:
                    residues_to_toggle.add(res)
                    self._hidden_seq_selected_residues.setdefault(
                        seq, set()).add(res)
        else:
            # Retrieve previously selected residues from cache
            for seq in sequences:
                residues = self._hidden_seq_selected_residues.pop(seq, ())
                residues_to_toggle.update(residues)
        selected = not hidden
        # TODO MSV-3094 Once sequence selection is undoable, add
        # _undoable=False
        self.seq_selection_model.setSelectionState(sequences, selected)
        self.res_selection_model.setSelectionState(residues_to_toggle,
                                                   selected,
                                                   _undoable=False)
    def setSeqFilterEnabled(self, enabled):
        if enabled == self._filter_enabled:
            return
        self._filter_enabled = enabled
        if self._name_query:
            self._emitHiddenSeqsChanged()
    def setSeqFilterQuery(self, query):
        if query == self._name_query:
            return
        self._name_query = query
        if self._filter_enabled:
            self._emitHiddenSeqsChanged()
    def _emitHiddenSeqsChanged(self, *, _invalidate=True):
        if _invalidate:
            self._invalidateHiddenSeqCache()
        any_hidden = self.anyHidden()
        self.signals.hiddenSeqsChanged.emit(any_hidden)
    def _emitHiddenSeqsChangedOnSeqChange(self):
        """
        If a change in seqs changes hidden sequences, emit hiddenSeqsChanged
        """
        previous_any_hidden = self.anyHidden()
        self._invalidateHiddenSeqCache()
        new_any_hidden = self.anyHidden()
        if new_any_hidden != previous_any_hidden:
            self._emitHiddenSeqsChanged(_invalidate=False)
    def getShownSeqs(self):
        """
        Return the sequences that are shown (not hidden or filtered out)
        :rtype: list
        """
        return [s for s, shown in zip(self, self.getSeqShownStates()) if shown]
    def getSeqShownStates(self):
        """
        Return whether each sequence in the alignment is shown (not hidden or
        filtered out)
        :rtype: list[bool]
        """
        if self._seq_shown_state_cache is None:
            if self._seqShownEarlyReturn():
                self._seq_shown_state_cache = [True] * len(self)
            else:
                self._seq_shown_state_cache = list(self._getSeqShownStates())
        return self._seq_shown_state_cache
    def anyHidden(self):
        if self._any_hidden is None:
            if self._seqShownEarlyReturn():
                self._any_hidden = False
            else:
                self._any_hidden = not all(self._getSeqShownStates())
        return self._any_hidden
    def _invalidateHiddenSeqCache(self):
        self._any_hidden = None
        self._seq_shown_state_cache = None
    def _seqShownEarlyReturn(self):
        """
        Check whether any sequences are hidden or filtering is enabled.
        Even if filtering is enabled, all sequences might still be shown.
        """
        return not self._hidden_seqs and (not self._filter_enabled or
                                          not self._name_query)
    def _getSeqShownStates(self):
        tests = []
        if self._hidden_seqs:
            tests.append(lambda seq: seq not in self._hidden_seqs)
        if self._filter_enabled:
            tests.append(lambda seq: self._name_query in seq.name)
        for idx, seq in enumerate(self):
            if idx == 0:
                yield True
            else:
                yield all(func(seq) for func in tests)
    def isSeqHidden(self, seq):
        return seq in self._hidden_seqs
    def _initHomologyCache(self):
        self._homology_status_cache = weakref.WeakKeyDictionary()
    def resetHomologyCache(self):
        changed_seqs = list(self._homology_status_cache.keys())
        self._initHomologyCache()
        for seq in changed_seqs:
            self.signals.homologyStatusChanged.emit(seq)
    def getHomologyStatus(self, seq):
        """
        Return the homology modeling status for the given sequence.
        :param seq: Sequence to check status
        :type seq: sequence.ProteinSequence
        :return: Homology modeling status
        :rtype: schrodinger.application.msv.gui.homology_modeling.hm_models.HomologyStatus or NoneType
        """
        return self._homology_status_cache.get(seq)
    def setHomologyStatus(self, seq, status):
        """
        Set the homology modeling status for the given sequence.
        :param seq: Sequence to set status
        :type seq: sequence.ProteinSequence
        :param status: Homology modeling status
        :type status: schrodinger.application.msv.gui.homology_modeling.hm_models.HomologyStatus
        """
        self._homology_status_cache[seq] = status
        self.signals.homologyStatusChanged.emit(seq)
    def _initHomologyCompositeResidues(self):
        self._homology_composite_residues = set()
    def resetHomologyCompositeResidues(self):
        """
        Reset the homology modeling composite residues, if any
        """
        curr_residues = self.homology_composite_residues
        if not curr_residues:
            return
        self.updateHomologyCompositeResidues(to_add=(), to_remove=curr_residues)
    @property
    def homology_composite_residues(self):
        """
        Residues to use for composite homology modeling
        :rtype: frozenset(residue.Residue)
        """
        return frozenset(self._homology_composite_residues)
    def updateHomologyCompositeResidues(self,
                                        to_add,
                                        to_remove,
                                        *,
                                        signal=True):
        """
        Update the residues to use for composite homology modeling.
        :param to_add: Residues to add
        :type to_add: collections.Iterable(residue.Residue)
        :param to_remove: Residues to remove
        :type to_remove: collections.Iterable(residue.Residue)
        :param signal: Whether to emit a signal due to the change
        :type signal: bool
        """
        if to_remove:
            self._homology_composite_residues.difference_update(to_remove)
        if to_add:
            self._homology_composite_residues.update(to_add)
        if signal and to_remove or to_add:
            self.signals.homologyCompositeResiduesChanged.emit()
    def isHomologyCompositeResidue(self, res):
        return res in self._homology_composite_residues
    def _initPairwiseConstraints(self):
        self._pairwise_constraints = _PairwiseConstraints()
    def resetPairwiseConstraints(self):
        self._pairwise_constraints.reset()
        self.signals.pairwiseConstraintsChanged.emit()
    @property
    def pairwise_constraints(self):
        return self._pairwise_constraints
    def setRefConstraint(self, res):
        self._pairwise_constraints.setRefConstraint(res)
        self.signals.pairwiseConstraintsChanged.emit()
    def setOtherConstraint(self, res):
        self._pairwise_constraints.setOtherConstraint(res)
        self.signals.pairwiseConstraintsChanged.emit()
    def _initHMLigandConstraints(self):
        self._hm_ligand_constraints = _HMLigandConstraints()
        self._hm_ligand_constraints.constraintsChanged.connect(
            self.signals.homologyLigandConstraintsChanged)
    @property
    def hm_ligand_constraints(self):
        yield from self._hm_ligand_constraints.constraints
    def hasHMLigandConstraints(self):
        return self._hm_ligand_constraints.hasConstraints()
    def isHMLigandConstraint(self, res, ligand):
        return self._hm_ligand_constraints.isConstraint(res, ligand)
    def setHMLigandConstraint(self, res, ligand):
        self._hm_ligand_constraints.handlePick(res, ligand)
    def clearHMLigandConstraints(self):
        self._hm_ligand_constraints.reset()
    def _removeConstraintsForRemovedSequences(self, start_idx, end_idx):
        sequences = self[start_idx:end_idx + 1]
        self._hm_ligand_constraints._onSequencesAboutToBeRemoved(sequences)
        self._hm_proximity_constraints._onSequencesAboutToBeRemoved(sequences)
    def _removeConstraintsForRemovedResidues(self, residues):
        self._hm_ligand_constraints._onResiduesAboutToBeRemoved(residues)
        self._hm_proximity_constraints._onResiduesAboutToBeRemoved(residues)
    def _initHMProximityConstraints(self):
        self._hm_proximity_constraints = _HMProximityConstraints()
        self._hm_proximity_constraints.constraintsChanged.connect(
            self.signals.homologyProximityConstraintsChanged)
    @property
    def proximity_constraints(self):
        return self._hm_proximity_constraints
    def setHMProximityConstraint(self, res):
        self._hm_proximity_constraints.setConstraint(res)
    def clearHMProximityConstraints(self):
        self._hm_proximity_constraints.reset()
    def alnSetResSelected(self):
        """
        Whether any selected residues are in sequences in an alignment set
        :rtype: bool
        """
        if not self.hasAlnSets() or not self.res_selection_model.hasSelection():
            return False
        seqs_in_sets = set().union(*self.alnSets())
        return any(res.sequence in seqs_in_sets
                   for res in self.res_selection_model.getSelection())
    def _getCurrentAlnSets(self, seqs):
        """
        Get the current alignment sets for all given sequences (and which
        sequences aren't part of any alignment set).  Also return the current
        set id counter value, as this will be required for undoing an alignment
        set change.
        :param seqs: The sequences to get the alignment sets for.
        :type seqs: List[sequece.Sequence]
        :return: A tuple of:
            - A dictionary of {set name: AlnSetInfo for the set} for all given
            sequences that are currently in a set.
            - The current set id counter value (i.e. the set id that will be
            given to the next set that's created).
            - A list of sequences that aren't currently in any set.
        :rype: tuple(dict(str, AlnSetInfo), int, set(sequence.Sequence))
        """
        aln_sets = {}
        no_set = set()
        for cur_seq in seqs:
            cur_aln_set = self.alnSetForSeq(cur_seq)
            if cur_aln_set is None:
                no_set.add(cur_seq)
            else:
                if cur_aln_set.name not in aln_sets:
                    aln_sets[cur_aln_set.name] = AlnSetInfo(cur_aln_set.set_id)
                aln_sets[cur_aln_set.name].seqs.add(cur_seq)
        return aln_sets, self._aln._set_id_counter, no_set
    def _restoreAlnSets(self,
                        old_aln_sets,
                        old_set_id_counter,
                        old_no_set=None):
        """
        Restore sequences to their previous alignment sets.  Also restore the
        previous set id counter value.
        :param old_aln_sets: A dictionary of {set names: `AlnSetInfo` containing
            sequences that should be restored to the set}.
        :type old_aln_sets: dict(str, AlnSetInfo)
        :param old_set_id_counter: The set id counter value to restore (i.e. the
            set id that will be given to the next set that's created).
        :type old_set_id_counter: int
        :param old_no_set: Sequences that should be removed from their current
            alignment set.
        :type old_no_set: set(sequence.Sequence) or None
        """
        if old_no_set is not None:
            self._aln._removeSeqsFromAlnSetNoReordering(old_no_set)
        for cur_set_name, (cur_set_id, cur_seqs) in old_aln_sets.items():
            self._aln._addSeqsToAlnSetNoReordering(cur_seqs, cur_set_name)
            self._aln.getAlnSet(cur_set_name).set_id = cur_set_id
        self._aln._set_id_counter = old_set_id_counter
    @command.do_command
    def addSeqsToAlnSet(self, seqs, set_name):
        # See alignment.BaseAlignment for method documentation
        seqs = set(seqs)
        old_aln_sets, old_set_id, old_no_set = self._getCurrentAlnSets(seqs)
        if set_name in old_aln_sets:
            seqs -= old_aln_sets[set_name].seqs
        if not seqs:
            return command.NO_COMMAND
        move_indices = self._getAddSeqsToAlnSetOrdering(seqs, set_name)
        move_redo, move_undo = self._getReorderSequencesFuncs(move_indices)
        def redo():
            move_redo()
            self._aln._addSeqsToAlnSetNoReordering(seqs, set_name)
        def undo():
            self._restoreAlnSets(old_aln_sets, old_set_id, old_no_set)
            move_undo()
        return redo, undo, f"Add {len(seqs)} Sequences to Set {set_name}"
    @command.do_command
    def removeSeqsFromAlnSet(self, seqs):
        # See alignment.BaseAlignment for method documentation
        old_aln_sets, old_set_id, old_no_set = self._getCurrentAlnSets(seqs)
        seqs = set(seqs) - old_no_set
        if not seqs:
            return command.NO_COMMAND
        move_indices = self._getRemoveFromAlnSetOrdering(seqs)
        move_redo, move_undo = self._getReorderSequencesFuncs(move_indices)
        def redo():
            move_redo()
            self._aln._removeSeqsFromAlnSetNoReordering(seqs)
        def undo():
            self._restoreAlnSets(old_aln_sets, old_set_id)
            move_undo()
        return redo, undo, f"Remove {len(seqs)} Sequences from Alignment Sets"
    @command.do_command
    def renameAlnSet(self, old_name, new_name):
        if old_name not in self._aln.alnSetNames():
            raise ValueError(f"No Set {old_name}")
        redo = partial(self._aln.renameAlnSet, old_name, new_name)
        undo = partial(self._aln.renameAlnSet, new_name, old_name)
        return redo, undo, f'Rename Alignment Set "{old_name}"'
    @command.do_command
    def gatherAlnSets(self):
        seq_indexes = self._getGatherAlnSetsReordering()
        redo, undo = self._getReorderSequencesFuncs(seq_indexes)
        return redo, undo, "Gather Alignment Sets"
[docs]class AlnSetInfo:
    """
    Information needed to undo the removal of an alignment set.
    :ivar set id: The set ID of the set
    :vartype set_id: int
    :ivar seqs: The sequences that were removed from the set
    :vartype seqs: set(sequence.Sequence)
    """
[docs]    def __init__(self, set_id):
        self.set_id = set_id
        self.seqs = set() 
    def __iter__(self):
        return iter((self.set_id, self.seqs)) 
[docs]class AnnotationRowInfo(typing.NamedTuple):
    """
    Tuple for information about an annotation row.
    :ivar seq: The sequence associated with the annotation. Is None for
        alignment/global annotations.
    :ivar ann: The annotation type
    :ivar idx: For multi-row annotations, the index of the row.
    """
    seq: typing.Union[sequence.ProteinSequence, None]
    ann: typing.Union[annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES,
                      annotation.ProteinAlignmentAnnotations.ANNOTATION_TYPES]
    idx: int = 0 
[docs]class SequenceAnnotationRowInfo(AnnotationRowInfo):
    def __new__(cls, seq, ann, idx=0):
        return super().__new__(cls, seq, ann, idx) 
[docs]class GlobalAnnotationRowInfo(AnnotationRowInfo):
    """
    Annotation info for global annotations.
    `seq` is always None because the annotation does not apply to a specific
    sequence.
    `idx` is always None because there are no multi-row global annotations.
    """
    def __new__(cls, ann):
        return super().__new__(cls, None, ann, None) 
# Register alignments as sequences so Hypothesis doesn't complain about
# sampling from them
collections.abc.Sequence.register(_ProteinAlignment)
[docs]class GuiProteinAlignment(json.JsonableClassMixin,
                          _ProteinAlignment,
                          metaclass=msv_utils.WrapperMetaClass,
                          wraps=alignment.ProteinAlignment,
                          wrapped_name='_aln',
                          instance_attrs=('annotations',)):
[docs]    def toJsonImplementation(self):
        json_dict = self._aln.toJsonImplementation()
        res_idxs = self.getResidueIndices(self._residue_highlights.keys(),
                                          sort=False)
        highlight_list = []
        for res_idx_tuple, color_tuple in zip(
                res_idxs, self._residue_highlights.values()):
            seq_idx, res_idx = res_idx_tuple
            color_list = list(color_tuple)
            highlight_list.append([seq_idx, res_idx, color_list])
        json_dict['res_highlights'] = highlight_list
        json_dict['seq_expansion'] = []
        for seq in self:
            json_dict['seq_expansion'].append(self.isSeqExpanded(seq))
        return json_dict 
[docs]    @classmethod
    def fromJsonImplementation(cls, json_obj, is_workspace=False):
        undo_aln = cls(is_workspace=is_workspace)
        deserialized_aln = alignment.ProteinAlignment.fromJson(json_obj)
        undo_aln._setInnerAlignment(deserialized_aln)
        highlights = json_obj['res_highlights']
        highlight_dict = {}
        for seq_idx, res_idx, color_list in highlights:
            res = undo_aln[seq_idx][res_idx]
            highlight_dict[res] = tuple(color_list)
        undo_aln._residue_highlights = highlight_dict
        for seq, expand in zip(undo_aln, json_obj['seq_expansion']):
            undo_aln.setSeqExpanded(seq, expand)
        return undo_aln 
[docs]    @json.adapter(version=49004)
    def adapter49004(cls, json_dict):
        json_dict['seq_expansion'] = []
        for _ in range(len(json_dict['sequences'])):
            json_dict['seq_expansion'].append(True)
        return json_dict 
[docs]    @json.adapter(version=48008)
    def adapter48008(cls, json_dict):
        # res_highlights was not serialized until 48008
        json_dict['res_highlights'] = []
        return json_dict  
[docs]class CombinedChainResidueSelectionModel(ResidueSelectionModel):
    """
    A residue selection model for `CombinedChainProteinAlignment`.  This
    selection model stores the selected combined-chain sequences and also
    updates the split-chain selection model.  That way, the selection changes
    won't be lost when the user toggles back to split-chain view.
    Note that the `ResidueSelectionModel` and
    `CombinedChainResidueSelectionModel` for the same alignment will differ in
    their return values for `isSingleBlockSelected` and `numBlocksSelected`
    because of differences in the underlying sequences (but not
    `isSingleBlockSingleSeqSelected`, since that method requires that all
    residues be in the same chain).
    """
[docs]    def __init__(self, aln, split_res_selection_model):
        """
        :param aln: The combined-chain alignment
        :type aln: CombinedChainProteinAlignment
        :param split_res_selection_model: The residue selection model for the
            split-chain alignment
        :type split_res_selection_model: ResidueSelectionModel
        """
        super().__init__(aln)
        self._split_res_selection_model = split_res_selection_model
        # Use sets instead of WeakSets because combined-chain sequences don't
        # store their CombinedChainResidueWrappers.  (Instead, they're generated
        # as needed and garbage collected as soon as they fall out of scope.)
        self._selection = set()
        self._old_selection = set()
        combined_residues = map(aln.combinedResForSplitRes,
                                split_res_selection_model.getSelection())
        self._selection.update(combined_residues) 
    def _toCombinedAndSplitResidues(self, residues):
        """
        Given an iterable of combined or split residues, return a tuple with
        the corresponding combined AND split residues.
        :return: A tuple with two lists containing the combined chain residues
            in the first and the split chain residues in the second.
        :rtype: tuple(list, list)
        """
        residues = list(residues)
        if not residues:
            return residues, residues
        if isinstance(residues[0], residue.CombinedChainResidueWrapper):
            split_residues = (res.split_res for res in residues)
        else:
            split_residues = residues
            residues = map(self.aln.combinedResForSplitRes, split_residues)
        return residues, split_residues
[docs]    def setCurrentSelectionState(self, residues, selected):
        residues, split_residues = self._toCombinedAndSplitResidues(residues)
        if not residues:
            return
        super().setCurrentSelectionState(residues, selected)
        self._split_res_selection_model.setCurrentSelectionState(
            split_residues, selected) 
[docs]    def finishCurrentSelection(self):
        super().finishCurrentSelection(_undoable=False)
        # Don't end macro until the very end
        self._split_res_selection_model.finishCurrentSelection(_undoable=True) 
[docs]    def setSelectionState(self, residues, selected, *, _undoable=True):
        """
        Set the selection state of the given residues.  Either combined-chain or
        split-chain residues may be given.
        :param residues: The residues to select or deselect.
        :type items: Iterable(residue.CombinedChainResidueWrapper) or
            Iterable(residue.AbstractSequenceElement)
        :param selected: Whether to select or deselect the residues.
        :type selected: bool
        :param bool _undoable: Whether to create an undoable command. Should
            only be passed by a command implementation.
        """
        residues, split_residues = self._toCombinedAndSplitResidues(residues)
        if not residues:
            return
        with contextlib.ExitStack() as stack:
            if _undoable:
                residues, desc = self._getSelectionResiduesAndDesc(
                    set(residues), selected)
                stack.enter_context(
                    command.compress_command(self.undo_stack, desc))
            super().setSelectionState(residues, selected, _undoable=_undoable)
            self._split_res_selection_model.setSelectionState(
                split_residues, selected, _undoable=_undoable) 
[docs]    def clearSelection(self, *, _undoable=True):
        # See parent class for method documentation
        with contextlib.ExitStack() as stack:
            if _undoable:
                desc = self._CLEAR_DESC
                stack.enter_context(
                    command.compress_command(self.undo_stack, desc))
            super().clearSelection(_undoable=_undoable)
            self._split_res_selection_model.clearSelection(_undoable=_undoable) 
[docs]    def isSingleBlockSingleSeqSelected(self):
        # See parent class for method documentation
        # Make sure all residues are from the same chain, not just the same
        # sequence
        sel_seq = {res.split_sequence for res in self._selection}
        if len(sel_seq) != 1:
            return False
        return super().isSingleBlockSingleSeqSelected()  
[docs]class CombinedChainSequenceSelectionModel(SequenceSelectionModel):
    """
    A sequence selection model for `CombinedChainProteinAlignment`.  Note that
    this selection model stores the selected combined-chain sequences and also
    updates the split-chain selection model.
    """
[docs]    def __init__(self, aln, split_seq_selection_model):
        """
        :param aln: The combined-chain alignment
        :type aln: CombinedChainProteinAlignment
        :param split_seq_selection_model: The sequence selection model for the
            split-chain alignment
        :type split_seq_selection_model: SequenceSelectionModel
        """
        super().__init__(aln)
        self._split_seq_selection_model = split_seq_selection_model
        # select the combined version of everything that's selected in the split
        # chain alignment
        initial_selection = set()
        for seq in aln:
            for chain in seq.chains:
                if split_seq_selection_model.isSelected(chain):
                    initial_selection.add(seq)
                    continue
        self._selection.update(initial_selection)
        self._old_selection.update(initial_selection) 
[docs]    def setSelectionState(self, sequences, selected, *, update_split_aln=True):
        """
        See parent class for method and positional argument documentation.
        :param update_split_aln: Whether to update sequence selection in the
            split-chain alignment.  This class is responsible for keeping split-
            chain and combined-chain sequence selection in sync, so this should
            only be False if sequence selection in the split-chain alignment
            will be updated separately.
        :type update_split_aln: bool
        """
        super().setSelectionState(sequences, selected)
        if update_split_aln:
            split_seqs = itertools.chain.from_iterable(
                seq.chains for seq in sequences)
            self._split_seq_selection_model.setSelectionState(
                split_seqs, selected) 
[docs]    def clearSelection(self):
        # See parent class for method documentation
        super().clearSelection()
        self._split_seq_selection_model.clearSelection()  
[docs]class CombinedChainAnnotationSelectionModel(AnnotationSelectionModel):
    """
    Class that tracks the selection state of sequence annotation as (sequence,
    annotation enum, annotation index) tuples
    """
[docs]    def __init__(self, aln, split_ann_selection_model):
        """
        :param aln: The combined-chain alignment
        :type aln: CombinedChainProteinAlignment
        :param split_ann_selection_model: The annotation selection model for the
            split-chain alignment
        :type split_ann_selection_model: AnnotationSelectionModel
        """
        super().__init__(aln)
        self._split_ann_selection_model = split_ann_selection_model 
[docs]    def setSelectionState(self, items, selected):
        # See parent class for method documentation
        super().setSelectionState(items, selected)
        split_items = set()
        for ann_info in items:
            if isinstance(ann_info, GlobalAnnotationRowInfo):
                split_items.add(ann_info)
            elif isinstance(ann_info, SequenceAnnotationRowInfo):
                combined_seq, *rest = ann_info
                for seq in combined_seq.chains:
                    new_info = SequenceAnnotationRowInfo(seq, *rest)
                    split_items.add(new_info)
            else:
                raise TypeError(
                    "Invalid selection item type, must be "
                    "GlobalAnnotationRowInfo or SequenceAnnotationRowInfo.")
        self._split_ann_selection_model.setSelectionState(split_items, selected) 
[docs]    def clearSelection(self):
        # See parent class for method documentation
        super().clearSelection()
        self._split_ann_selection_model.clearSelection()  
[docs]class GuiCombinedChainProteinAlignment(
        _ProteinAlignment,
        metaclass=msv_utils.WrapperMetaClass,
        wraps=alignment.CombinedChainProteinAlignment,
        wrapped_name='_aln',
        instance_attrs=('annotations',)):
    """
    An undoable alignment containing combined-chain sequences
    (`sequence.CombinedChainProteinSequence` objects).
    """
    _ANCHORED_RES_JSON_KEY = "anchored_residues"
[docs]    def __init__(self, split_undoable_aln, *, chains_to_combine=None):
        """
        :param split_undoable_aln: An undoable alignment containing split chain
            sequences.  Note that, unlike
            `alignment.CombinedChainProteinAlignment`, changes made to the
            combined-chain alignment will automatically be reflected in the
            split-chain alignment.  Also note that the reverse is not
            necessarily true; changes made to the split-chain alignment may not
            update the combined-chain alignment.  If you modify the split-chain
            alignment and want a corresponding combined-chain alignment, you
            should create a new `GuiCombinedChainProteinAlignment` instance.
        :type split_undoable_aln: ProteinAlignment
        :param chains_to_combine: Information about which split-chain sequences
            in `split_undoable_aln` should be included in which combined-chain
            sequence.  Should be a list of lists of indices.  Each index refers
            to the split-chain sequence at that position of
            `split_undoable_aln`, and split-chain sequences that are listed
            together will be combined into the same combined-chain sequence.
            Each split-chain sequence from `split_undoable_aln` must be
            referenced exactly once.
        :type chains_to_combine: list[list[int]]
        """
        combined_seq_aln = alignment.CombinedChainProteinAlignment(
            split_undoable_aln, chains_to_combine=chains_to_combine)
        self._split_undoable_aln = split_undoable_aln
        super().__init__(aln=combined_seq_aln)
        super().setUndoStack(split_undoable_aln.undo_stack)
        self._is_workspace = split_undoable_aln._is_workspace
        split_color_map = split_undoable_aln.getHighlightColorMap()
        # We intentionally change _residue_highlights from an IdDict to a
        # regular dictionary since CombinedChainResidueWrappers need to be
        # compared using equality, not identity.
        self._residue_highlights = {
            self.combinedResForSplitRes(res): color
            for res, color in split_color_map.items()
        } 
    def _initSelectionModels(self):
        # See parent class for method documentation
        self.res_selection_model = CombinedChainResidueSelectionModel(
            self, self._split_undoable_aln.res_selection_model)
        self.seq_selection_model = CombinedChainSequenceSelectionModel(
            self, self._split_undoable_aln.seq_selection_model)
        self.ann_selection_model = CombinedChainAnnotationSelectionModel(
            self, self._split_undoable_aln.ann_selection_model)
    def __deepcopy__(self, memo):
        # figure out which split-chain sequences are grouped together
        indices = [[
            self._split_undoable_aln.index(chain) for chain in seq.chains
        ] for seq in self]
        split_aln = copy.deepcopy(self._split_undoable_aln, memo)
        combined_aln = self.__class__(split_aln, chains_to_combine=indices)
        self._copyAnchoringTo(combined_aln._aln)
        self._copyAlnSetsTo(combined_aln._aln)
        return combined_aln
[docs]    def setSeqExpanded(self, seq, expanded=True):
        for chain in seq.chains:
            self._split_undoable_aln.setSeqExpanded(chain, expanded)
        self.signals.seqExpansionChanged.emit(seq, expanded) 
[docs]    def isSeqExpanded(self, seq):
        return any(
            self._split_undoable_aln.isSeqExpanded(chain)
            for chain in seq.chains) 
[docs]    def jsonDataWithoutSplitAln(self):
        """
        Return a JSON-able dictionary that can be used to recreate this object
        using `fromJsonAndSplitAln`.
        :rtype: dict
        """
        return {self._ANCHORED_RES_JSON_KEY: self._getAnchoredResidueIndices()} 
[docs]    @classmethod
    def fromJsonAndSplitAln(cls, split_aln, json_dict):
        """
        Restore a serialized object.
        :param split_aln: The split-chain alignment.
        :type split_aln: ProteinAlignment
        :param json_dict: A dictionary returned by `jsonDataWithoutSplitAln`
        :type json_dict: dict
        :return: The restored alignment
        :rtype: GuiCombinedChainProteinAlignment
        """
        combined_aln = cls(split_aln)
        indices = json_dict[cls._ANCHORED_RES_JSON_KEY]
        combined_aln._aln._anchorResidueIndices(indices)
        return combined_aln 
[docs]    def setUndoStack(self, undo_stack):
        # See parent class for method documentation
        super().setUndoStack(undo_stack)
        self._split_undoable_aln.setUndoStack(undo_stack) 
[docs]    def setReferenceSeq(self, seq):
        # See alignment.BaseAlignment for documentation
        self._assertCanSetReferenceSeq()
        desc = self._setReferenceSeqDesc(seq)
        split_aln_ordering = self._getSplitAlnRefSeqReordering(seq)
        with command.compress_command(self.undo_stack, desc):
            super().setReferenceSeq(seq)
            self._split_undoable_aln.reorderSequences(split_aln_ordering) 
    def _getSplitAlnRefSeqReordering(self, combined_seq):
        """
        Returns an ordering for the split-chain alignment that will make the
        first chain of the specified combined-chain sequence the reference
        sequence and move all other chains of the combined-chain sequence
        to the top of the alignment (immediately after the reference sequence).
        :param combined_seq: The combined-chain reference sequence
        :type combined_seq: sequence.CombinedChainProteinSequence
        :return: The requested ordering
        :rtype: list[int]
        """
        ref_chains = [
            self._split_undoable_aln.index(chain)
            for chain in combined_seq.chains
        ]
        other_chains = [
            i for i in range(len(self._split_undoable_aln))
            if i not in ref_chains
        ]
        return ref_chains + other_chains
[docs]    def addSeqs(self, seqs, index=None, replace_selection=False):
        """
        Add multiple sequences to the alignment.  Note that either split-chain
        sequences or combined-chain sequences may be added (but not both at the
        same time).  This method accepts split-chain sequences so that a caller
        can add sequences generated by seqio.py (or structure_model.py) without
        needing to first excplicitly combine chains.
        :param seqs: Sequences to add.
        :type seqs: list[sequence.ProteinSequence] or
            list[sequence.CombinedChainProteinSequence]
        :param index: The index at which to insert; if None, seqs are appended.
            Must be None if adding single-chain sequences.
        :type index: int
        :param replace_selection: Whether to select the newly added sequences
            and deselect all other sequences.  If False, selection will not be
            changed.
        :type replace_selection: bool
        """
        if not seqs:
            return
        elif self._isCombinedChainSeq(seqs[0]):
            self._addCombinedSeqs(seqs, index, replace_selection)
        elif index is not None:
            raise RuntimeError("Cannot specify start when adding split-chain "
                               "sequences to a combined-chain alignment.")
        else:
            self._addSplitSeqs(seqs, replace_selection) 
    def _addCombinedSeqs(self, seqs, index, replace_selection):
        """
        Add multiple combined-chain sequences to the alignment.
        :param seqs: Sequences to add.
        :type seqs: list[sequence.CombinedChainProteinSequence]
        :param index: The index at which to insert; if None, seqs are appended.
        :type index: int
        :param replace_selection: Whether to select the newly added sequences
            and deselect all other sequences.  If False, selection will not be
            changed.
        :type replace_selection: bool
        """
        split_seqs = self._allChains(seqs)
        if index is None:
            split_index = None
        elif index == 0:
            split_index = 0
        else:
            insert_split_after = self._getSplitAfterSeq(self[index - 1])
            split_index = self._split_undoable_aln.index(insert_split_after) + 1
        desc = self._addSeqsDesc(seqs)
        with command.compress_command(self.undo_stack, desc):
            super().addSeqs(seqs, index, replace_selection)
            self._split_undoable_aln.addSeqs(split_seqs, split_index,
                                             replace_selection)
            self._emitSyncWsResSelectionOnRedo(seqs)
    def _allChains(self, seqs):
        """
        Return all chains for a given list of combined-chain sequences.
        :param seqs: The combined-chain sequences
        :type seqs: list[sequence.CombinedChainProteinSequence]
        :return: The split-chain sequences
        :rtype: list[sequence.ProteinSequence]
        """
        return list(
            itertools.chain.from_iterable(cur_seq.chains for cur_seq in seqs))
    def _addSplitSeqs(self, split_seqs, replace_selection):
        """
        Add multiple split-chain sequences to the alignment.
        :param split_seqs: Sequences to add.
        :type split_seqs: list[sequence.ProteinSequence]
        :param replace_selection: Whether to select the newly added sequences
            and deselect all other sequences.  If False, selection will not be
            changed.
        :type replace_selection: bool
        """
        desc = self._addSeqsDesc(split_seqs)
        with command.compress_command(self.undo_stack, desc):
            super().addSeqs(split_seqs, replace_selection=replace_selection)
            self._split_undoable_aln.addSeqs(
                split_seqs, replace_selection=replace_selection)
            self._emitSyncWsResSelectionOnRedo(split_seqs)
    def _addSeqs(self, seqs, index, replace_selection):
        # See _ProteinAlignment._addSeqs for method documentation
        to_select = self._aln.addSeqs(seqs, index)
        if replace_selection:
            self.seq_selection_model.clearSelection()
            # We don't update sequence selection in the split alignment because
            # we haven't added the new sequences to it yet.  They'll be selected
            # as part of the self._split_undoable_aln.addSeqs() call.
            self.seq_selection_model.setSelectionState(to_select,
                                                       True,
                                                       update_split_aln=False)
    @command.do_command
    def _emitSyncWsResSelectionOnRedo(self, seqs):
        """
        Return a command that emits `syncWsResSelection` on redo, but does
        nothing on undo.  (On undo, the sequences are about to be removed, so
        there's no need to worry about residue selection.)
        :param seqs: The sequences to synchronize the residue selection of.  May
            be either split-chain or combined-chain sequences.  (Residue
            selection will be updated in both the split-chain and combined-chain
            alignment regardless of sequence type.)
        :type seqs: list[sequence.AbstractSequence]
        """
        redo = partial(self.signals.syncWsResSelection.emit, seqs)
        return redo, lambda: None, "Synchronize workspace residue selection"
[docs]    def removeSeqs(self, seqs):
        """
        Remove multiple combined-chain sequences from the alignment.
        :param seqs: Sequences to remove.  Note that these must be combined-
            chain sequences (`sequence.CombinedChainProteinSequence`), not
            split-chain sequences (`sequence.ProteinSequence`)
        :type seqs: list[sequence.CombinedChainProteinSequence]
        """
        if self.getReferenceSeq() in seqs:
            self._assertCanRemoveRef()
        split_seqs = self._allChains(seqs)
        desc = self._removeSeqsDesc(seqs)
        with command.compress_command(self.undo_stack, desc):
            self._removeSeqs(seqs)
            self._split_undoable_aln._removeSeqs(split_seqs) 
[docs]    def renameSeq(self, seq, new_name):
        # See alignment.BaseAlignment for documentation
        desc = self._renameSeqDesc(seq)
        with command.compress_command(self.undo_stack, desc):
            super().renameSeq(seq, new_name)
            for chain in seq.chains:
                self._split_undoable_aln.renameSeq(chain, new_name) 
[docs]    def clear(self):
        # See alignment.BaseAlignment for documentation
        with command.compress_command(self.undo_stack, self._CLEAR_DESC):
            super().clear()
            self._split_undoable_aln.clear() 
    def _getReorderSequencesFuncs(self, seq_indices):
        # See _ProteinAlignment for method documentation
        split_indices = []
        for i in seq_indices:
            for chain in self[i].chains:
                split_index = self._split_undoable_aln.index(chain)
                split_indices.append(split_index)
        undo_indices = self._getUndoSequenceOrdering(seq_indices)
        undo_split_indices = self._split_undoable_aln._getUndoSequenceOrdering(
            split_indices)
        def redo():
            self._aln.reorderSequences(seq_indices)
            self._split_undoable_aln._aln.reorderSequences(split_indices)
        def undo():
            self._aln.reorderSequences(undo_indices)
            self._split_undoable_aln._aln.reorderSequences(undo_split_indices)
        return redo, undo
[docs]    def moveSelectedSequences(self, after_seq):
        # See _ProteinAlignment for method documentation
        after_split_seq = self._getSplitAfterSeq(after_seq)
        selected_seqs = self.seq_selection_model.getSelection()
        selected_seqs.discard(self.getReferenceSeq())
        split_seqs_to_move = self._allChains(selected_seqs)
        split_indices = self._split_undoable_aln._getMoveSequenceAfterIndices(
            after_split_seq, split_seqs_to_move)
        with command.compress_command(self.undo_stack,
                                      self._MOVE_SELECTED_SEQS_DESC):
            super().moveSelectedSequences(after_seq)
            self._split_undoable_aln.reorderSequences(split_indices) 
    def _getSplitAfterSeq(self, after_seq):
        """
        If combined-chain sequences are to be inserted after the specified
        combined-chain sequence, return the split-chain sequence that the
        equivalent split-chain sequences should be inserted after.  By inserting
        after the returned split-chain sequence we ensure that:
        - The combined-chain alignment order will be preserved if we were to
          discard the current combined-chain alignment and generate a new one
          from the split-chain alignment.
        - We don't insert the split-chain sequences in between two adjacent
          sequences from the same protein.
        :param after_seq: The combined-chain sequence that the combined-chain
            sequences are going to be inserted after.
        :type after_seq: sequence.CombinedChainProteinSequence
        :return: The split-chain sequence that the split-chain sequences should
            be inserted after.
        :rtype: sequence.ProteinSequence
        """
        after_split_seq = None
        for split_seq in self._split_undoable_aln:
            if split_seq in after_seq.chains:
                after_split_seq = split_seq
            elif after_split_seq is not None:
                return after_split_seq
        if after_split_seq is not None:
            return after_split_seq
        raise RuntimeError("No after_seq chains found in the split-sequence "
                           "alignment.")
    def _setResHighlights(self, color_map):
        # See _ProteinAlignment for method documentation
        super()._setResHighlights(color_map)
        split_color_map = {
            res.split_res: color for res, color in color_map.items()
        }
        self._split_undoable_aln._setResHighlights(split_color_map)
[docs]    @msv_utils.const
    def findPattern(self, pattern):
        """
        Finds a specified PROSITE pattern in all sequences.
        :param pattern: PROSITE pattern to search in sequences. See
            `protein.sequence.find_generalized_pattern` for documentation.
        :type pattern: str
        :returns: List of matching residues
        :rtype: list[protein.residue.CombinedChainResidueWrapper]
        """
        residues = self._split_undoable_aln.findPattern(pattern)
        return list(map(self.combinedResForSplitRes, residues)) 
[docs]    @command.do_command
    def alignChainStarts(self):
        # See CombinedChainProteinAlignment for method documentation
        if self.getInterChainAnchors():
            raise alignment.AnchoredResidueError()
        gaps, chain_starts, end_of_ref = self._gapsToAddToAlignChainStarts()
        def redo():
            self._aln._addGapsToChainStartsAndEnds(gaps)
            return chain_starts, end_of_ref
        undo = partial(self._aln._removeGapsFromChainStartsAndEnds, gaps)
        return redo, undo, "Align Chain Starts" 
[docs]    @command.do_command
    def adjustChainStarts(self, num_gaps):
        # See CombinedChainProteinAlignment for method documentation
        gaps_to_remove, gaps_to_add = self._adjustChainStartsToGaps(num_gaps)
        self._validateGapsToRemoveFromChainStartAndEnds(gaps_to_remove)
        def redo():
            with self.suspendAnchors():
                self._aln._removeGapsFromChainStartsAndEnds(gaps_to_remove)
                self._aln._addGapsToChainStartsAndEnds(gaps_to_add)
        def undo():
            with self.suspendAnchors():
                self._aln._removeGapsFromChainStartsAndEnds(gaps_to_add)
                self._aln._addGapsToChainStartsAndEnds(gaps_to_remove)
        return redo, undo, "Adjust Chain Starts" 
    def _validateGapsToRemoveFromChainStartAndEnds(self, gaps):
        """
        Make sure that we can remove the specified number of gaps from the
        starts and ends of each chain in each sequence.
        :param gaps: The numbers of gaps to remove, formatted as
            gaps_to_remove[sequence_index][chain_index] =
            (gaps to remove to the start of the chain,
            gaps to remove to the end of the chain)
        :type gaps: list[list[tuple(int, int)]]
        :raise AssertionError: If some of the sequence elements to be removed
            aren't actually gaps.
        """
        for seq, cur_gaps in zip(self, gaps):
            seq.validateGapsToRemoveFromChainStartAndEnds(cur_gaps)
    def _createRemoveElementsUndo(self, elements):
        # See parent class for method documentation.
        # We must reimplement this method here so that gaps adjacent to chain
        # boundaries are restored to the correct chain.
        orig_indices = self.getResidueIndices(elements)
        elems_to_restore = collections.defaultdict(list)
        for s_idx, r_idx in orig_indices:
            seq = self._aln[s_idx]
            elem = seq[r_idx]
            chain = elem.split_sequence
            elems_to_restore[seq].append((r_idx, elem, chain))
        has_anchors = len(self.getAnchoredResidues()) > 0
        if has_anchors:
            gaps_to_remove = dict()
            for seq, ele_info in elems_to_restore.items():
                eles = [t[1] for t in ele_info]
                gaps = self._getAnchorConservingGapIdxs(seq, eles)
                gaps_to_remove[seq] = gaps
        selected = self.res_selection_model.getSelection()
        sel_to_remove = selected.intersection(elements)
        def undo():
            with self.suspendAnchors():
                for seq, res_list in elems_to_restore.items():
                    if has_anchors:
                        gap_idxs = gaps_to_remove[seq]
                        gaps = [seq[g_idx] for g_idx in gap_idxs]
                        self._aln.removeElements(gaps)
                    for r_idx, res, chain in res_list:
                        self._aln._addElementByChain(seq, r_idx, chain, res)
            self.res_selection_model.setSelectionState(sel_to_remove,
                                                       True,
                                                       _undoable=False)
        return undo
    def _getRestoreGapsMethod(self):
        # See parent class for method documentation.
        # We must reimplement this method here so that gaps adjacent to chain
        # boundaries are restored to the correct chain.
        original_gaps = self.getGaps()
        gap_info = [[(gap.idx_in_seq, gap.split_sequence)
                     for gap in seq_gaps]
                    for seq_gaps in original_gaps]
        selection = self.res_selection_model.getSelection()
        sel_gaps = [elem for elem in selection if elem.is_gap]
        sel_gap_idxs = self.getResidueIndices(sel_gaps)
        def undo():
            with self.suspendAnchors():
                self._aln.removeAllGaps()
                for seq, seq_gaps in zip(self, gap_info):
                    for (index, chain) in seq_gaps:
                        self._aln._addElementByChain(seq, index, chain,
                                                     residue.Gap())
            gaps_to_select = [
                self._aln[res_i][seq_i] for (res_i, seq_i) in sel_gap_idxs
            ]
            self.res_selection_model.setSelectionState(gaps_to_select,
                                                       True,
                                                       _undoable=False)
        return undo
[docs]    def expandSelectionToFullChain(self):
        """
        Select all the residues in any sequence in which there is an already
        selected residue
        """
        sel_residues = self.res_selection_model.getSelection()
        split_seqs = (res.split_sequence for res in sel_residues)
        split_residues = set.union(*(set(seq) for seq in split_seqs))
        with command.compress_command(self.undo_stack,
                                      self._EXPAND_SELECTION_TO_FULL_CHAIN):
            self.res_selection_model.setSelectionState(split_residues, True)