Source code for schrodinger.application.msv.gui.view

import enum
from contextlib import contextmanager
from functools import partial

import schrodinger
from schrodinger.application.msv import command
from schrodinger.application.msv.gui import dialogs
from schrodinger.application.msv.gui import gui_models
from schrodinger.application.msv.gui import row_delegates
from schrodinger.application.msv.gui import stylesheets
from schrodinger.application.msv.gui import viewconstants
from schrodinger.application.msv.gui import viewmodel
from schrodinger.application.msv.gui.menu import EnabledTargetSpec
from schrodinger.application.msv.gui.viewconstants import TOP_LEVEL
from schrodinger.application.msv.gui.viewconstants import Adjacent
from schrodinger.application.msv.gui.viewconstants import CustomRole
from schrodinger.application.msv.gui.picking import PickMode
from schrodinger.application.msv.gui.viewconstants import ResidueFormat
from schrodinger.application.msv.gui.viewconstants import RowType
from schrodinger.application.msv.gui.viewconstants import SortTypes
from schrodinger.infra import util
from schrodinger.models import mappers
from schrodinger.protein import alignment
from schrodinger.protein import annotation
from schrodinger.protein import constants as protein_constants
from schrodinger.protein import sequence
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import table_speed_up
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.appframework2.application import get_application
from schrodinger.utils.scollections import DefaultFactoryDictMixin

maestro = schrodinger.get_maestro()

PROT_ALN_ANN_TYPES = annotation.ProteinAlignmentAnnotations.ANNOTATION_TYPES
PROT_SEQ_ANN_TYPES = annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES
PRED_ANN_TYPES = annotation.ProteinSequenceAnnotations.PRED_ANNOTATION_TYPES

_EditorType = enum.Enum("_EditorType", ("Change", "Replace", "Insert"))
_DragAndDropState = enum.Enum("_DragAndDropState",
                              ("Hover", "MouseDown", "Dragging"))


class _DefaultFactoryDataCache(DefaultFactoryDictMixin,
                               table_speed_up.DataCache):
    pass


[docs]class StyledQMenu(QtWidgets.QMenu):
[docs] def __init__(self, parent=None): super().__init__(parent) self.setToolTipsVisible(True) self.setStyleSheet(stylesheets.MENU)
def _setActionNotImplemented(self, action): action.setToolTip("Not implemented") font = action.font() font.setStrikeOut(True) action.setFont(font) action.setDisabled(True)
[docs]class MappedMenu(mappers.MapperMixin, StyledQMenu): model_class = gui_models.MenuEnabledModel
[docs] def __init__(self, parent=None): super().__init__(parent) self._defineActions() self._populateMenu() self._setupMapperMixin()
def _defineActions(self): """ Define `QAction` to be used for the menu. """ raise NotImplementedError() def _defineMenuActionMap(self): """ Method that returns an iterable of 2-tuples or self._SEPARATOR. Tuples must contain a `QAction` and a signal to emit when the action has been emitted, in that order. """ raise NotImplementedError() def _populateMenu(self): menu_mapping = self._defineMenuActionMap() for menu_item in menu_mapping: if menu_item is self._SEPARATOR: self.addSeparator() else: action, action_value = menu_item self.addAction(action) if action_value is None: continue try: subactions = iter(action_value) except TypeError: action.triggered.connect(action_value, Qt.QueuedConnection) else: self._addSubmenu(action, subactions) def _addSubmenu(self, action, subactions): submenu = StyledQMenu(self) for menu_item in subactions: if menu_item is self._SEPARATOR: submenu.addSeparator() else: subaction, subaction_signal = menu_item submenu.addAction(subaction) if subaction_signal is not None: subaction.triggered.connect(subaction_signal, Qt.QueuedConnection) action.setMenu(submenu)
[docs]class AbstractElementContextMenu(MappedMenu): """ Context menu for selecting on elements in the alignment. """ expandAlongColumnsRequested = QtCore.pyqtSignal() expandFromReferenceRequested = QtCore.pyqtSignal() expandToFullChainRequested = QtCore.pyqtSignal() invertSelectionRequested = QtCore.pyqtSignal() copyRequested = QtCore.pyqtSignal() deleteGapsOnlyRequested = QtCore.pyqtSignal() highlightSelectionRequested = QtCore.pyqtSignal() removeHighlightsRequested = QtCore.pyqtSignal() hideColumnsRequested = QtCore.pyqtSignal() anchorRequested = QtCore.pyqtSignal() unanchorRequested = QtCore.pyqtSignal() editSeqInPlaceRequested = QtCore.pyqtSignal(bool) copyToNewTabRequested = QtCore.pyqtSignal() _SEPARATOR = object()
[docs] def __init__(self, parent=None): super().__init__(parent) self.setAnchorMode(True) self._setActionNotImplemented(self.hide_columns)
[docs] def defineMappings(self): M = self.model_class mappings = [ (self.copy, M.can_copy_residues), (self.delete_gaps_only, M.can_delete_gaps), (self.replace_with_gaps, M.can_replace_res_with_gaps), (self.remove_highlights, M.can_remove_highlights), ] return [(EnabledTargetSpec(action), abstract_param) for (action, abstract_param) in mappings]
def _defineActions(self): self.expand_selection = QtGui.QAction("Expand Selection") self.expand_along_columns = QtGui.QAction("Along Columns") self.expand_from_reference = QtGui.QAction("From Reference Sequence") self.expand_to_full_chain = QtGui.QAction("To Full Chain") self.invert_selection = QtGui.QAction("Invert Selection") self.copy = QtGui.QAction("Copy") self.copy_to_new_tab = QtGui.QAction("Copy Selection to New Tab") self.delete_gaps_only = QtGui.QAction("Delete Gaps Only") self.replace_with_gaps = QtGui.QAction("Replace With Gaps") self.edit_seq_in_place = QtGui.QAction("Edit Sequence In Place", checkable=True) self.highlight_selection = QtGui.QAction("Highlight Selection...") self.remove_highlights = QtGui.QAction("Remove Highlights") self.hide_columns = QtGui.QAction("Hide Columns") self.unanchorOrAnchor = QtGui.QAction()
[docs] def setAnchorEnabled(self, enabled): self.unanchorOrAnchor.setEnabled(enabled)
[docs] def setExpandFromReferenceEnabled(self, enabled): """ Set whether Expand From Reference is enabled :param enabled: Whether Expand From Reference should be enabled :type enabled: bool """ self.expand_from_reference.setEnabled(enabled)
[docs] def setAnchorMode(self, new_mode): self.unanchorOrAnchor.triggered.disconnect() if new_mode is True: self.unanchorOrAnchor.setText("Anchor") self.unanchorOrAnchor.triggered.connect(self.anchorRequested, Qt.QueuedConnection) else: self.unanchorOrAnchor.setText("Clear Anchoring") self.unanchorOrAnchor.triggered.connect(self.unanchorRequested, Qt.QueuedConnection)
[docs]class ResidueContextMenu(AbstractElementContextMenu): searchForMatchesRequested = QtCore.pyqtSignal() replaceWithGapsRequested = QtCore.pyqtSignal() deleteRequested = QtCore.pyqtSignal() expandBetweenGapsRequested = QtCore.pyqtSignal()
[docs] def defineMappings(self): parent_mappings = super().defineMappings() M = self.model_class mappings = [ (self.search_for_matches, M.can_copy_residues), (self.delete, M.can_delete_residues), ] parent_mappings.extend((EnabledTargetSpec(action), abstract_param) for (action, abstract_param) in mappings) return parent_mappings
def _defineActions(self): super()._defineActions() self.search_for_matches = QtGui.QAction("Search For Matches") self.delete = QtGui.QAction("Delete") self.delete.setShortcut("Shift+Del") self.expand_between_gaps = QtGui.QAction("Between Gaps") def _defineExpandSelectionActionMap(self): return ( (self.expand_between_gaps, self.expandBetweenGapsRequested), (self.expand_along_columns, self.expandAlongColumnsRequested), (self.expand_from_reference, self.expandFromReferenceRequested), (self.expand_to_full_chain, self.expandToFullChainRequested), ) def _defineMenuActionMap(self): expand_selection_mapping = self._defineExpandSelectionActionMap() return ((self.expand_selection, expand_selection_mapping), (self.invert_selection, self.invertSelectionRequested), (self.copy_to_new_tab,self.copyToNewTabRequested), (self.search_for_matches, self.searchForMatchesRequested), self._SEPARATOR, (self.copy, self.copyRequested), (self.delete, self.deleteRequested), (self.delete_gaps_only, self.deleteGapsOnlyRequested), (self.replace_with_gaps, self.replaceWithGapsRequested), (self.edit_seq_in_place, self.editSeqInPlaceRequested), self._SEPARATOR, (self.highlight_selection, self.highlightSelectionRequested), (self.remove_highlights, self.removeHighlightsRequested), self._SEPARATOR, # MSV-3357: Hiding unimplemented features for beta #(self.hide_columns, self.hideColumnsRequested), (self.unanchorOrAnchor, self.anchorRequested)) # yapf: disable
[docs]class AnnoResContextMenu(ResidueContextMenu): expandToAnnoValsRequested = QtCore.pyqtSignal(object, int)
[docs] def setClickedAnno(self, anno, ann_index): """ Store the annotation associated with this context menu. Must be called before showing the menu. :param anno: Annotation :type anno: annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES :param ann_index: Annotation index (only used for multi-row annotations) :type ann_index: int """ self._anno_data = (anno, ann_index) enable = anno.can_expand tooltip = "" if enable else f"{anno.title} can't be expanded" action = self.expand_to_same_anno_vals action.setEnabled(enable) action.setToolTip(tooltip)
def _defineActions(self): super()._defineActions() self.expand_to_same_anno_vals = QtGui.QAction( "To Same Annotation Values") def _defineExpandSelectionActionMap(self): mapping = super()._defineExpandSelectionActionMap() return ( (self.expand_to_same_anno_vals, self._emitExpandToAnnoValsRequested), self._SEPARATOR, ) + mapping def _emitExpandToAnnoValsRequested(self): self.expandToAnnoValsRequested.emit(*self._anno_data)
[docs]class GapContextMenu(AbstractElementContextMenu): expandAlongGapsRequested = QtCore.pyqtSignal() deselectGapsRequested = QtCore.pyqtSignal() def _defineActions(self): super()._defineActions() # The "delete gaps only" action has a different name in the gap menu self.delete_gaps_only.setText("Delete Selected Gaps") self.expand_along_gaps = QtGui.QAction("Along Gaps") self.deselect_gaps = QtGui.QAction("Deselect All Gaps") def _defineMenuActionMap(self): expand_selection_mapping = ( (self.expand_along_gaps, self.expandAlongGapsRequested), (self.expand_along_columns, self.expandAlongColumnsRequested), (self.expand_from_reference, self.expandFromReferenceRequested), (self.expand_to_full_chain, self.expandToFullChainRequested), ) return ((self.expand_selection, expand_selection_mapping), (self.deselect_gaps, self.deselectGapsRequested), (self.invert_selection, self.invertSelectionRequested), (self.copy_to_new_tab, self.copyToNewTabRequested), self._SEPARATOR, (self.copy, self.copyRequested), (self.delete_gaps_only, self.deleteGapsOnlyRequested), (self.edit_seq_in_place, self.editSeqInPlaceRequested), self._SEPARATOR, (self.highlight_selection, self.highlightSelectionRequested), (self.remove_highlights, self.removeHighlightsRequested), self._SEPARATOR, # MSV-3357: Hiding unimplemented features for beta #(self.hide_columns, self.hideColumnsRequested), (self.unanchorOrAnchor, self.anchorRequested)) # yapf: disable
[docs]class AlignmentInfoContextMenu(MappedMenu): """ Class for context menus in the info section of the view. :ivar setAsReferenceSeqRequested: Signal emitted to set selected sequence as the reference sequence :ivar renameRequested: Signal emitted to rename the selected sequence :ivar duplicateInPlaceRequested: Duplicates sequence in place :ivar duplicateAtBottomRequested: Duplicates sequence at bottom :ivar duplicateAtTopRequested: Duplicates sequence at top :ivar duplicateAsRefSeqRequested: Duplicates sequence as reference sequence :ivar duplicateIntoNewTabRequested: Duplicates sequence into a new tab :ivar sortRequested: Signal emitted to sort the sequences in ascending or descending order by a given metric. Emits an object as the metric to sort by 'viewconstants.SortTypes' Emits a boolean for whether to sort in reverse or not 'bool' :ivar moveSeqRequested: Signal emitted to move the selected sequence. Emits a list of the selected `sequence.Sequence` objects and the `viewconstants.Direction`. We use `object` as the param type because of a known issue with `enum_speedup`. :ivar getStructurePdbRequested: Signal emitted to request downloading PDB structures for the selected sequence(s) :ivar translateDnaRnaRequested: Signal emitted to translate DNA to RNA :ivar unlinkFromEntryRequested: Signal emitted to unlink from entry :ivar linkToEntryRequested: Signal emitted to link to entry :ivar selectAllResiduesRequested: Signal emitted to select all residues of all selected sequences in the alignment. :ivar deselectAllResiduesRequested: Signal emitted to clear the selection :ivar deleteRequested: Signal emitted to delete the selected sequence(s) :ivar exportSeqsRequested: Signal emitted to export the selected sequence(s) :ivar hideRequested: Signal emitted to hide the selected sequence(s) :ivar alnSetCreateRequested: Signal emitted to add all selected sequences to a new alignment set. The user should be prompted for the set name. :ivar alnSetAddRequested: Signal emitted to add all selected sequences to an existing alignment set. The signal is emitted with the name of the set to add the sequences to. :ivar alnSetRemoveRequested: Signal emitted to remove all selected sequences from their alignment sets. """ setAsReferenceSeqRequested = QtCore.pyqtSignal() renameRequested = QtCore.pyqtSignal() duplicateInPlaceRequested = QtCore.pyqtSignal() duplicateAtBottomRequested = QtCore.pyqtSignal() duplicateAtTopRequested = QtCore.pyqtSignal() duplicateAsRefSeqRequested = QtCore.pyqtSignal() duplicateIntoNewTabRequested = QtCore.pyqtSignal() sortRequested = QtCore.pyqtSignal(object, bool) moveSeqRequested = QtCore.pyqtSignal(object) getStructurePdbRequested = QtCore.pyqtSignal() translateDnaRnaRequested = QtCore.pyqtSignal() unlinkFromEntryRequested = QtCore.pyqtSignal() linkToEntryRequested = QtCore.pyqtSignal() selectAllResiduesRequested = QtCore.pyqtSignal() deselectAllResiduesRequested = QtCore.pyqtSignal() deleteRequested = QtCore.pyqtSignal() exportSeqsRequested = QtCore.pyqtSignal() hideRequested = QtCore.pyqtSignal() alnSetCreateRequested = QtCore.pyqtSignal() alnSetAddRequested = QtCore.pyqtSignal(str) alnSetRemoveRequested = QtCore.pyqtSignal() _SEPARATOR = object()
[docs] def __init__(self, parent=None): """ Initializes a context menu in the info section of the view. :param parent: Parent of this menu :type parent: `QtWidgets.QWidget` """ self._aln_set_add_actions = [] super().__init__(parent)
[docs] def defineMappings(self): M = self.model_class mappings = [ (self.delete_seq, M.can_delete_sequences), (self.duplicate, M.can_duplicate_sequence), (self.duplicate_in_place, M.can_duplicate_seq_same_tab), (self.duplicate_at_bottom, M.can_duplicate_seq_same_tab), (self.duplicate_at_top, M.can_duplicate_seq_same_tab), (self.duplicate_as_ref, M.can_duplicate_as_ref), (self.move_down, M.can_move_sequence), (self.move_to_bottom, M.can_move_sequence), (self.move_top, M.can_move_sequence), (self.move_up, M.can_move_sequence), (self.set_as_ref_seq, M.can_set_as_ref), (self.rename_seq, M.can_rename_seq), (self.aln_set_remove, M.can_remove_from_aln_set), (self.sort_ascending_chain, M.can_sort_by_chain), (self.sort_descending_chain, M.can_sort_by_chain), (self.get_struct_pdb, M.can_get_pdb_sts), (self.unlink_from_entry, M.can_link_or_unlink_sequences), ] # yapf: disable mappings = [(EnabledTargetSpec(action), abstract_param) for (action, abstract_param) in mappings] mappings.append( (mappers.TargetSpec(slot=self._updateAlnSets), M.aln_set_names)) return mappings
[docs] def updateUnlinkedFromSequences(self, can_unlink_seq): if can_unlink_seq: self.unlink_from_entry.setText('Unlink from Entry...') else: self.unlink_from_entry.setText('Link to Entry...')
[docs] def updateCanTranslate(self, can_translate): self.translate_dna_rna.setEnabled(can_translate)
def _defineMenuActionMap(self): duplicate_seq_mapping = ( (self.duplicate_in_place, self.duplicateInPlaceRequested), (self.duplicate_at_bottom, self.duplicateAtBottomRequested), (self.duplicate_at_top, self.duplicateAtTopRequested), (self.duplicate_as_ref, self.duplicateAsRefSeqRequested), (self.duplicate_into_new_tab, self.duplicateIntoNewTabRequested), (self.duplicate_into_existing_tab, None), ) # yapf: disable emit_sort = self.sortRequested.emit sorts = viewconstants.SortTypes sort_ascending_mapping = ( (self.sort_ascending_name, lambda: emit_sort(sorts.Name, False)), (self.sort_ascending_chain, lambda: emit_sort(sorts.ChainID, False)), (self.sort_ascending_gaps, lambda: emit_sort(sorts.NumGaps, False)), (self.sort_ascending_length, lambda: emit_sort(sorts.Length, False)), (self.sort_ascending_id, lambda: emit_sort(sorts.Identity, False)), (self.sort_ascending_sim, lambda: emit_sort(sorts.Similarity, False)), (self.sort_ascending_cons, lambda: emit_sort(sorts.Conservation, False)), (self.sort_ascending_score, lambda: emit_sort(sorts.Score, False)), ) # yapf: disable sort_descending_mapping = ( (self.sort_descending_name, lambda: emit_sort(sorts.Name, True)), (self.sort_descending_chain, lambda: emit_sort(sorts.ChainID, True)), (self.sort_descending_gaps, lambda: emit_sort(sorts.NumGaps, True)), (self.sort_descending_length, lambda: emit_sort(sorts.Length, True)), (self.sort_descending_id, lambda: emit_sort(sorts.Identity, True)), (self.sort_descending_sim, lambda: emit_sort(sorts.Similarity, True)), (self.sort_descending_cons, lambda: emit_sort(sorts.Conservation, True)), (self.sort_descending_score, lambda: emit_sort(sorts.Score, True)), ) # yapf: disable self._addSubmenu(self.sort_ascending, sort_ascending_mapping) self._addSubmenu(self.sort_descending, sort_descending_mapping) sort_submenu = QtWidgets.QMenu(self) sort_submenu.addAction(self.sort_ascending) sort_submenu.addAction(self.sort_descending) self.sort_all.setMenu(sort_submenu) self.aln_set_add_menu = QtWidgets.QMenu(self) self.aln_set_add.setMenu(self.aln_set_add_menu) # This menu will be enabled and populated in _updateAlnSets once there # are sets to add to self.aln_set_add.setEnabled(False) aln_set_menu_item = (self.aln_set, None) menu_actions = [ (self.set_as_ref_seq, self.setAsReferenceSeqRequested), (self.rename_seq, self.renameRequested), (self.duplicate, duplicate_seq_mapping), self._SEPARATOR, (self.move_top, lambda: self.moveSeqRequested.emit(viewconstants.Direction.Top)), (self.move_up, lambda: self.moveSeqRequested.emit(viewconstants.Direction.Up)), (self.move_down, lambda: self.moveSeqRequested.emit(viewconstants.Direction.Down)), (self.move_to_bottom, lambda: self.moveSeqRequested.emit(viewconstants.Direction.Bottom)), (self.sort_all, None), self._SEPARATOR, aln_set_menu_item, (self.get_struct_pdb, self.getStructurePdbRequested), (self.translate_dna_rna, self.translateDnaRnaRequested), (self.unlink_from_entry, self._requestLinkOrUnlinking), self._SEPARATOR, (self.select_all_residues, self.selectAllResiduesRequested), (self.deselect_all_residues, self.deselectAllResiduesRequested), self._SEPARATOR, (self.export_seqs, self.exportSeqsRequested), (self.hide_seq, self.hideRequested), (self.delete_seq, self.deleteRequested), ] # yapf: disable return tuple(menu_actions)
[docs] def createAlnSetMenu(self, in_set=True): """ Create Alignment Set menu - with different QActions depending on whether the sequence already belongs to a set or not. :param in_set: Whether the sequence is already in an alignment set or not. :type in_set: bool. """ if in_set: self.aln_set_add.setText("Move to Set") alignment_set_mapping = ( (self.aln_set_remove, None), self._SEPARATOR, (self.aln_set_add, None), ) else: self.aln_set_add.setText("Add to Set") alignment_set_mapping = ( (self.aln_set_create, None), self._SEPARATOR, (self.aln_set_add, None), ) self._addSubmenu(self.aln_set, alignment_set_mapping)
def _requestLinkOrUnlinking(self): if self.model.can_unlink_sequences: self.unlinkFromEntryRequested.emit() else: self.linkToEntryRequested.emit() def _defineActions(self): self.set_as_ref_seq = QtGui.QAction("Set as Reference") self.rename_seq = QtGui.QAction("Rename") self.duplicate = QtGui.QAction("Duplicate") self.duplicate_in_place = QtGui.QAction("In Place") self.duplicate_at_bottom = QtGui.QAction("At Bottom") self.duplicate_at_top = QtGui.QAction("At Top") self.duplicate_as_ref = QtGui.QAction("As Reference Sequence") self.duplicate_into_new_tab = QtGui.QAction("Into New Tab") self.duplicate_into_existing_tab = QtGui.QAction("Into Existing Tab") self.move_top = QtGui.QAction("Move to Top") self.move_up = QtGui.QAction("Move Up") self.move_down = QtGui.QAction("Move Down") self.move_to_bottom = QtGui.QAction("Move to Bottom") self.sort_ascending_name = QtGui.QAction("Name") self.sort_ascending_chain = QtGui.QAction("Chain ID") self.sort_ascending_gaps = QtGui.QAction("Number of Gaps") self.sort_ascending_length = QtGui.QAction("Length") self.sort_ascending_id = QtGui.QAction("Identity %") self.sort_ascending_sim = QtGui.QAction("Similarity %") self.sort_ascending_cons = QtGui.QAction("Conservation %") self.sort_ascending_score = QtGui.QAction("Score") self.sort_descending_name = QtGui.QAction("Name") self.sort_descending_chain = QtGui.QAction("Chain ID") self.sort_descending_gaps = QtGui.QAction("Number of Gaps") self.sort_descending_length = QtGui.QAction("Length") self.sort_descending_id = QtGui.QAction("Identity %") self.sort_descending_sim = QtGui.QAction("Similarity %") self.sort_descending_cons = QtGui.QAction("Conservation %") self.sort_descending_score = QtGui.QAction("Score") self.sort_ascending = QtGui.QAction("Sort Sequences Ascending") self.sort_descending = QtGui.QAction("Sort Sequences Descending") self.sort_all = QtGui.QAction("Sort All by") self.get_struct_pdb = QtGui.QAction("Get Structure from PDB") self.translate_dna_rna = QtGui.QAction("Translate DNA/RNA") self.unlink_from_entry = QtGui.QAction("Unlink From Entry...") self.select_all_residues = QtGui.QAction("Select All Residues") self.deselect_all_residues = QtGui.QAction("Deselect All Residues") self.export_seqs = QtGui.QAction("Export...") self.hide_seq = QtGui.QAction("Hide") self.delete_seq = QtGui.QAction("Delete") self.aln_set = QtGui.QAction("Alignment Set") self.aln_set_create = QtGui.QAction("Create Set...") self.aln_set_add = QtGui.QAction() self.aln_set_remove = QtGui.QAction("Remove from Set") self.aln_set_create.triggered.connect(self.alnSetCreateRequested) self.aln_set_remove.triggered.connect(self.alnSetRemoveRequested) def _updateAlnSets(self): """ Regenerate the "Add to Set" submenu using the current alignment set names. This method is called automatically whenever the alignment set names are changed. """ set_names = self.model.aln_set_names self.aln_set_add_menu.clear() self._aln_set_add_actions.clear() self.aln_set_add.setEnabled(bool(set_names)) if set_names: for cur_name in sorted(set_names): action = QtGui.QAction(cur_name) action.triggered.connect( partial(self.alnSetAddRequested.emit, cur_name), Qt.QueuedConnection) self.aln_set_add_menu.addAction(action) self._aln_set_add_actions.append(action) self.aln_set_add_menu.addSeparator() create_new_set = QtGui.QAction("Create New...") create_new_set.triggered.connect(self.alnSetCreateRequested) self.aln_set_add_menu.addAction(create_new_set) self._aln_set_add_actions.append(create_new_set)
[docs]class AbstractHeaderContextMenu(StyledQMenu): """ Base class for header context menus in the view. Subclasses must override sort_type or provide a method to set it :ivar sortRequested: Signal emitted to request sorting the sequences in ascending or descending order by a given sort type. Emits `viewconstants.SortTypes` as an object due to typing difficulties of enum_speedup. """ sortRequested = QtCore.pyqtSignal(object, bool) sort_type = NotImplemented
[docs] def __init__(self, parent=None): super().__init__(parent) self.sort_ascending_act = self.addAction("Sort Ascending") self.sort_descending_act = self.addAction("Sort Descending") self.sort_ascending_act.triggered.connect( partial(self.sortByColumnRequested, False), Qt.QueuedConnection) self.sort_descending_act.triggered.connect( partial(self.sortByColumnRequested, True), Qt.QueuedConnection)
[docs] def sortByColumnRequested(self, reverse): self.sortRequested.emit(self.sort_type, reverse)
[docs]class AlignmentMetricsContextMenu(AbstractHeaderContextMenu): """ Header context menu for the metrics view :cvar metric_to_sort_type: Mapping between column type and sort type. :vartype metric_to_sort_type: dict(table_helper.Column, viewconstants.SortTypes) """ metric_to_sort_type = { viewmodel.AlignmentMetricsColumns.Identity: SortTypes.Identity, viewmodel.AlignmentMetricsColumns.Similarity: SortTypes.Similarity, viewmodel.AlignmentMetricsColumns.Conservation: SortTypes.Conservation, viewmodel.AlignmentMetricsColumns.Score: SortTypes.Score }
[docs] def setSortType(self, metric): """ :param metric: The column type :type metric: viewmodel.AlignmentMetricsColumn """ if isinstance(metric, viewmodel.SequencePropertyColumn): self.sort_type = metric.getSeqProp() return self.sort_type = self.metric_to_sort_type[metric]
[docs]class SeqChainContextMenu(AbstractHeaderContextMenu): """ Header context menu for the chain column in the info view """ sort_type = viewconstants.SortTypes.ChainID
[docs]class SeqTitleContextMenu(AbstractHeaderContextMenu): """ Header context menu for the tile column in the info view :ivar findInListRequested: Signal to request find sequence in list """ findInListRequested = QtCore.pyqtSignal() sort_type = viewconstants.SortTypes.Name
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.addSeparator() self.find_act = self.addAction("Find in List...") self.find_act.triggered.connect(self.findInListRequested, Qt.QueuedConnection)
[docs]class StructureColumnContextMenu(StyledQMenu): """ Class for context menus in the structure icon column of the alignment info view. """ INCLUDE_TEXT = "Include Entry in Workspace" EXCLUDE_TEXT = "Exclude Entry from Workspace"
[docs] def __init__(self, parent=None): super().__init__(parent) self.entry_ids = set() self.included = False self.toggle_inclusion = self.addAction(self.INCLUDE_TEXT) self.toggle_inclusion.triggered.connect(self.onInclusionChangeRequested, Qt.QueuedConnection) self.addSeparator() self.exclude_all_others = self.addAction("Exclude All Others") self.exclude_all_others.triggered.connect(self.excludeAllOthers, Qt.QueuedConnection) if maestro: self.proj = maestro.project_table_get()
[docs] def setSelectedSeqs(self, sel_seqs): """ :param sel_seqs: The currently selected sequences. :type sel_seqs: iterable(sequence.Sequence) """ self.entry_ids = { seq.entry_id for seq in sel_seqs if seq.entry_id not in ("", None) }
[docs] def setIncluded(self, included): """ :param included: Whether the clicked on sequence has a structure included in the workspace :type included: bool """ self.included = included if included: text = self.EXCLUDE_TEXT else: text = self.INCLUDE_TEXT self.toggle_inclusion.setText(text)
[docs] def onInclusionChangeRequested(self): if self.included: self.excludeEntryInWorkspace() else: self.includeEntryInWorkspace()
[docs] @qt_utils.maestro_required def excludeEntryInWorkspace(self): currently_included = {row.entry_id for row in self.proj.included_rows} new_included = currently_included - self.entry_ids if len(new_included) != len(currently_included): if len(new_included) == 0: self._clearMaestroInclusion() return self.proj.includeRows(new_included, exclude_others=True)
@qt_utils.maestro_required def _clearMaestroInclusion(self): currently_included = {row.entry_id for row in self.proj.included_rows} maestro.command(f"entrywsexclude entry {','.join(currently_included)}")
[docs] @qt_utils.maestro_required def includeEntryInWorkspace(self): self.proj.includeRows(self.entry_ids, exclude_others=False)
[docs] @qt_utils.maestro_required def excludeAllOthers(self): currently_included = {row.entry_id for row in self.proj.included_rows} new_included = currently_included & self.entry_ids if len(new_included) != len(currently_included): if len(new_included) == 0: self._clearMaestroInclusion() return self.proj.includeRows(new_included, exclude_others=True)
[docs]class AbstractSelectionProxyModel(QtCore.QItemSelectionModel): """ A "hollowed out" selection model that simply notifies the viewmodel about selection instead of keeping track of selection itself. Instead, selection information is stored with the alignment. This makes selection much faster (since QItemSelectionModels are incredibly slow when there are a lot of selection ranges) is makes it easier to keep data synchronized. Note that isSelected() will always return False for this selection model. Since isSelected() isn't virtual, any reimplementation here won't be called from C++, so we avoid reimplementing the method to avoid potential confusion. As a result, selection information should always be fetched via `model.data(index, ROLE)` instead of through this selection model. Note that this will cause problems with Ctrl+click behavior. See `CtrlClickToggleMixin` to fix those issues. :cvar ROLE: The Qt role used for selection information. Subclasses must redefine this value. :vartype ROLE: int """ ROLE = None
[docs] def select(self, selection, flags): # See Qt documentation for method documentation # This function is overloaded, so "selection" may actually be an index if isinstance(selection, QtCore.QModelIndex): index = selection if index.isValid(): selection = QtCore.QItemSelection(index, index) else: selection = QtCore.QItemSelection() if flags & self.Clear: self._clearSelection() if flags & self.Select: selection_state = True elif flags & self.Deselect: selection_state = False elif flags & self.Toggle: first_index = selection.indexes()[0] selected = self.model().data(first_index, self.ROLE) selection_state = not selected else: return self._setSelectionState(selection, selection_state)
def _clearSelection(self): raise NotImplementedError def _setSelectionState(self, selection, selection_state): """ Select or deselect the items corresponding to `selection`. :type selection: QtCore.QItemSelection :param selection_state: Whether to select or deselect the items :type selection_state: bool """ raise NotImplementedError
[docs]class ResidueSelectionProxyModel(AbstractSelectionProxyModel): """ Selection model for selecting residues in an alignment. Intended to be used with `AbstractAlignmentView`. Note that this model only supports selecting single residues. For selecting residue ranges, use `AbstractAlignmentView.model().setResRangeSelectionState()` instead. Storing residue selection information with the alignment allows us to synchronize selection state with the maestro workspace. """ ROLE = CustomRole.ResSelected
[docs] def __init__(self, model=None, parent=None): # See Qt documentation for argument documentation self._ignore_next_select_call = False if model is not None: model.columnsAboutToBeRemoved.connect(self._ignoreNextSelectCall) super().__init__(model, parent)
def _ignoreNextSelectCall(self): """ Ignore the next call to `select`. `QItemSelectionModel` deselects any residues from columns that are about to be removed. We don't want that behavior, though, since the alignment itself takes care of updating the selection as necessary. As such, this method allows us to ignore the `select` call that comes from `QItemSelectionModelPrivate::_q_columnsAboutToBeRemoved`. """ self._ignore_next_select_call = True
[docs] def select(self, selection, flags): if self._ignore_next_select_call: self._ignore_next_select_call = False elif isinstance(selection, QtCore.QItemSelection): raise RuntimeError( "ResidueSelectionProxyModel only supports selecting single " "residues. Use setResRangeSelectionState() instead.") else: super().select(selection, flags)
def _clearSelection(self): self.model().clearResSelection() def _setSelectionState(self, selection, selection_state): self.model().setResSelectionState(selection, selection_state)
[docs]class SequenceSelectionProxyModel(AbstractSelectionProxyModel): """ Selection model for selecting entire sequences in an alignment. Intended to be used with `AlignmentInfoView`. The actual selection model is stored with the alignment, similar to how residue selection is stored. This allows us to sync selection across wrap rows on top of improving performance over Qt's selection models. """ ROLE = CustomRole.SeqSelected
[docs] def __init__(self, model, parent, **kwargs): super().__init__(model, parent, **kwargs) # This prevents unexpected behavior when shift+clicking after # reordering the alignment. model.orderChanged.connect(self.clearCurrentIndex)
def _clearSelection(self): self.model().clearSeqSelection() def _setSelectionState(self, selection, selection_state): self.model().setSeqSelectionState(selection, selection_state)
[docs]class CtrlClickToggleMixin: """ A mixin for `QAbstractItemViews` that reimplements `selectionCommand` to fix broken Ctrl+click behavior caused by using `AbstractSelectionProxyModel` selection models. QAbstractItemView::mousePressEvent replaces toggle commands with either select or deselect based on selection_model.isSelected(), but AbstractSelectionProxyModel.isSelected() always returns False. To get around this, we replace toggle commands with select or deselect here using the correct selection status. :cvar SELECTION_ROLE: The Qt role used for selection information. This value must be defined in the view class. :vartype SELECTION_ROLE: int :ivar _drag_selection_flag: The selection command to apply when dragging the mouse. This mimics the effects of QAbstractItemViewPrivate::ctrlDragSelectionFlag and prevents us from constantly toggling selection if the mouse is Ctrl+dragged within the same cell. :vartype _drag_selection_flag: int """ SELECTION_ROLE = None
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._drag_selection_flag = QtCore.QItemSelectionModel.Select
[docs] def selectionCommand(self, index, event=None): # See QAbstractItemView documentation for method documentation QISM = QtCore.QItemSelectionModel command = super().selectionCommand(index, event) if command & QISM.Toggle: command &= ~QISM.Toggle if event is not None and event.type() == event.MouseMove: command |= self._drag_selection_flag else: if self.model().data(index, self.SELECTION_ROLE): new_command = QISM.Deselect else: new_command = QISM.Select command |= new_command if event is not None and event.type() == event.MouseButtonPress: self._drag_selection_flag = int(new_command) return command
[docs]class SeqExpansionViewMixin: """ A mixin for views that synchronize sequence expansion via the model. Must be mixed in with QTreeView. """ # A context manager for ignoring changes in group expansion to avoid # infinite loops _ignoreExpansion = util.flag_context_manager("_ignore_expansion")
[docs] def __init__(self, parent=None): self._ignore_expansion = False self._group_by = None super().__init__(parent) self.setExpandsOnDoubleClick(False)
[docs] def setModel(self, model): # See Qt documentation for method documentation old_model = self.model() if old_model is not None: old_model.seqExpansionChanged.disconnect( self._seqExpansionChangedInModel) old_model.groupByChanged.disconnect(self._groupByChanged) old_model.rowsInserted.disconnect( self._syncExpansionForInsertedRows) old_model.modelReset.disconnect(self._syncExpansion) old_model.layoutChanged.disconnect(self._syncExpansion) super().setModel(model) if model is not None: model.seqExpansionChanged.connect(self._seqExpansionChangedInModel) model.groupByChanged.connect(self._groupByChanged) model.rowsInserted.connect(self._syncExpansionForInsertedRows) model.modelReset.connect(self._syncExpansion) model.layoutChanged.connect(self._syncExpansion) self._group_by = model.getGroupBy() self._syncExpansion()
def _syncExpansion(self): """ If we are grouping the view by Type, then simply expand the entire view. Otherwise, sync the views expansion state to the models expansion state. """ if self._group_by is viewconstants.GroupBy.Type: self.expandAll() else: row_count = self.model().rowCount() if row_count > 0: last_row = row_count - 1 self._syncExpansionForRows(0, last_row) def _syncExpansionForRows(self, start, end): """ Sync the expand/collapse state for the specified row numbers. This function should only be called when in group-by-sequence mode. :param start: The first row to sync state for. :type start: int :param end: The last row to sync state for. :type end: int """ model = self.model() aln = model.getAlignment() seq_role = viewconstants.CustomRole.Seq with self._ignoreExpansion(): for i in range(start, end + 1): index = model.index(i, 0) # We get seq from model since aln[i] is not necessarily the # same as the sequence this row corresponds to (indexes can be # offset by rows like ruler) seq = self.model().data(index, role=seq_role) if seq is None: continue self.setExpanded(index, aln.isSeqExpanded(seq)) def _syncExpansionForInsertedRows(self, parent, start, end): """ Sync the expand/collapse state of inserted sequences. See the QAbstractItemModel.rowsInserted signal documentation for argument documentation. """ if parent.isValid(): self._syncExpansionForRows(parent.row(), parent.row()) elif self._group_by is viewconstants.GroupBy.Type: self.expandAll() else: self._syncExpansionForRows(start, end)
[docs] def expandAll(self): if self._group_by is viewconstants.GroupBy.Sequence: self._setModelAllExpanded(True) super().expandAll()
[docs] def collapseAll(self): if self._group_by is viewconstants.GroupBy.Sequence: self._setModelAllExpanded(False) super().collapseAll()
def _setModelAllExpanded(self, expanded): model = self.model() for row_idx in range(model.rowCount()): idx = model.index(row_idx, 0) model.setData(idx, expanded, viewconstants.CustomRole.SeqExpanded)
[docs] def expand(self, idx): self.setExpanded(idx, True)
[docs] def collapse(self, idx): self.setExpanded(idx, False)
[docs] def setExpanded(self, idx, expanded): if (self._group_by is viewconstants.GroupBy.Sequence and not self._ignore_expansion): self.model().setData(idx, expanded, viewconstants.CustomRole.SeqExpanded) super().setExpanded(idx, expanded)
def _seqExpansionChangedInModel(self, indices, expanded): """ Respond to an expansion change coming from the model. :param indices: A list of all indices (`QtCore.QModelIndex`) that should be expanded or collapsed. :type indices: list :param expanded: True if the indices was expanded. False if they were collapsed. :type expanded: bool """ with self._ignoreExpansion(): for index in indices: self.setExpanded(index, expanded) def _groupByChanged(self, group_by): """ Respond to row groups being changed between group-by-sequence and group-by-annotation. :param group_by: Whether rows are grouped by sequence or annotation :type group_by: `viewconstants.GroupBy` """ self._group_by = group_by self._syncExpansion()
[docs]class AbstractAlignmentView(SeqExpansionViewMixin, CtrlClickToggleMixin, widgetmixins.MessageBoxMixin, QtWidgets.QTreeView): """ Class for viewing sequence alignments in a table :ivar residueHovered: Signal emitted when a residue cell is hovered :ivar residueUnhovered: Signal emitted when a residue cell is unhovered :ivar residueMiddleClicked: Signal emitted when middle-button is clicked on a residue cell :ivar sequenceEditModeRequested: Signal emitted when 'Edit Sequence in Place' context menu-item is toggled. Emitted with bool - whether the menu-item is toggled on or off. :ivar copyToNewTabRequested: Signal emitted to duplicate the sequences with only selected residues, to a new tab. :ivar _per_row_data_cache: A cache for data that is the same for all cells in a row. Stored as {(row, internal_id): (row_type, per_row_data, row_del, per_cell_roles)}, where per_row_data is {role: data}. Note that our models ensure that all indices in the same row have the same internal ID. :vartype _per_row_data_cache: table_speed_up.DataCache :ivar _per_cell_data_cache: A cache of data needed for painting cells. {(row, column, internal_id): {role: data}}. :vartype _per_cell_data_cache: table_speed_up.DataCache :ivar _heights_by_row_type: A mapping of {row_type: row height in pixels}. :vartype _heights_by_row_type: dict :ivar _row_delegates: A list of all row delegates. Each row delegate appears only once on the list even if it applies to more than one row type. :vartype _row_delegates: list(row_delegates.AbstractDelegate) :ivar _row_delegates_by_row_type: A mapping of {row_type: row delegate}. :vartype _row_delegates_by_row_type: dict(enum.Enum: row_delegates.AbstractDelegate) """ SELECTION_ROLE = CustomRole.ResOrColSelected residueHovered = QtCore.pyqtSignal(object) residueUnhovered = QtCore.pyqtSignal() residueMiddleClicked = QtCore.pyqtSignal(object) openColorPanelRequested = QtCore.pyqtSignal() sequenceEditModeRequested = QtCore.pyqtSignal(bool) copyToNewTabRequested = QtCore.pyqtSignal()
[docs] def __init__(self, undo_stack, parent=None): """ :param undo_stack: The undo stack. Used to group commands that happen on mouse drag into a single macro. :type undo_stack: schrodinger.application.msv.command.UndoStack :param parent: The Qt parent :type parent: QtWidgets.QWidget or None """ super().__init__(parent) self._is_workspace = False # Note that the undo stack should only be used to begin and end macros. # The commands themselves should be generated in gui_alignment. self._undo_stack = undo_stack self._drag_possible = False self._drag_state = None self._drag_anchor_x = 0 self._drag_failed_moves = 0 self._sel_prior_to_mouse_click = None self._current_selection_mode = False self._initContextMenus() self._setupHeaders() self._num_cols_visible = 0 self.setSelectionBehavior(self.SelectItems) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._update_cell_size_timer = self._initUpdateCellSizeTimer() self._update_row_wrap_timer = self._initUpdateRowWrapTimer() self._update_drag_and_drop_possible_timer = self._initUpdateDragAndDropPossibleTimer( ) # Not patching this timer as it's only started in dragMoveEvent self._auto_scroll_timer = QtCore.QTimer(self) self._auto_scroll_timer.timeout.connect(self._doAutoScroll) self._auto_scroll_count = 0 self.setMouseTracking(True) # force editors to be opened manually (i.e. via a toolbar button or a # keyboard shortcut we set up in keyPressEvent) self.setEditTriggers(self.NoEditTriggers) self._hovered_index = None self._edit_mode = False self.setTextElideMode(Qt.ElideNone) self.setRootIsDecorated(False) self.setIndentation(0) self.header().setStretchLastSection(False) # The number of rows in the table can be much, much higher than the # number of sequences if annotations are shown and row-wrapping is # turned on, so we use DataCache even for the per-row data cache. self._per_row_data_cache = _DefaultFactoryDataCache( self._populatePerRowCache, 50000) self._per_cell_data_cache = table_speed_up.DataCache(75000) self._heights_by_row_type = {} # we use a Qt delegate for editing (but *not* for painting, which is # handled by row delegates for performance reasons) self._edit_delegate = EditorDelegate(self) self._edit_delegate.moveChangeEditorRequested.connect( self._moveChangeEditor) self.setItemDelegate(self._edit_delegate) # row delegates handle painting self._row_delegates = [] self._row_delegates_by_row_type = {} for cls in row_delegates.all_delegates(): if not cls.ANNOTATION_TYPE: continue instance = cls() self._row_delegates.append(instance) ann_types = cls.ANNOTATION_TYPE if not isinstance(ann_types, (tuple, list, set)): ann_types = [ann_types] for row_type in ann_types: self._row_delegates_by_row_type[row_type] = instance self._col_width = 0 # In Qt 5.12, the minimum section size is far too large and we don't # actually want a minimum anyway, so we just set it to a single pixel. self.header().setMinimumSectionSize(1) # _cols_to_paint and _col_left_edges are used to pass information from # paintEvent to drawRow since QTreeView::paintEvent does the actual # calling of drawRow self._cols_to_paint = None self._col_left_edges = None
def _initUpdateCellSizeTimer(self): # hook for patching timer = QtCore.QTimer(self) timer.setSingleShot(True) timer.setInterval(0) timer.timeout.connect(self._updateCellSize) return timer def _initUpdateRowWrapTimer(self): # hook for patching timer = QtCore.QTimer(self) timer.setSingleShot(True) timer.setInterval(0) timer.timeout.connect(self._updateRowWrapping) return timer def _initUpdateDragAndDropPossibleTimer(self): # hook for patching timer = QtCore.QTimer(self) timer.setSingleShot(True) timer.setInterval(0) timer.timeout.connect(self._updateDragAndDropPossible) return timer def __copy__(self): copy_view = self.__class__(self._undo_stack, parent=self.parent()) copy_view.setModel(self.model()) copy_view.setIsWorkspace(self._is_workspace) return copy_view
[docs] def setLightMode(self, enabled): """ Enable or disable light mode on all delegates """ for delegate in self._row_delegates: delegate.setLightMode(enabled)
[docs] def setIsWorkspace(self, is_workspace): self._is_workspace = is_workspace
def _setupHeaders(self): """ Configure the table headers """ header = self.header() header.setSectionResizeMode(header.Fixed) header.hide()
[docs] def setModel(self, model): """ Set the model and ensure that changes to the model columns trigger updates in the delegates See Qt documentation for argument documentation """ cell_size_signals = [ "modelReset", "rowsInserted", "rowsRemoved", "layoutChanged", "residueFormatChanged", "textSizeChanged", "rowHeightChanged" ] cache_signals = [ "rowsInserted", "rowsRemoved", "rowsMoved", "columnsInserted", "columnsRemoved", "columnsMoved", "modelReset", "layoutChanged", "dataChanged" ] old_model = self.model() if old_model is not None: for signal_name in cell_size_signals: cur_signal = getattr(old_model, signal_name) cur_signal.disconnect(self._update_cell_size_timer.start) for signal_name in cache_signals: cur_signal = getattr(old_model, signal_name) cur_signal.disconnect(self._clearCachesOnModelChange) old_model.residueSelectionChanged.disconnect( self._onResidueSelectionChanged) old_model.alnSetChanged.disconnect(self._updateDragAndDropPossible) super().setModel(model) selection_model = ResidueSelectionProxyModel(model, self) self.setSelectionModel(selection_model) if old_model is None: # If this is the first time we're setting a model, make sure that # cell size and row wrapping are set correctly. We don't need to # worry about this on subsequent calls, since AnnotationProxyModel # keeps RowWrapProxyModel up to date even when row wrapping is # turned off self._updateCellSize(update_row_wrap=False) # Delay the row wrapping update to make sure that the panel is fully # loaded when it happens. Otherwise, we won't yet know the correct # width of this view self._update_row_wrap_timer.start() for signal_name in cell_size_signals: cur_signal = getattr(model, signal_name) # We use _update_cell_size_timer to avoid unnecessary repetition of # _updateCellSize(). cur_signal.connect(self._update_cell_size_timer.start) for signal_name in cache_signals: cur_signal = getattr(model, signal_name) cur_signal.connect(self._clearCachesOnModelChange) model.residueSelectionChanged.connect(self._onResidueSelectionChanged) model.alnSetChanged.connect(self._updateDragAndDropPossible)
[docs] def mousePressEvent(self, event): model = self.model() pos = event.pos() index = self.indexAt(pos) pick_mode = model.getPickingMode() row_type = index.data(CustomRole.RowType) if (pick_mode is PickMode.HMBindingSite and row_type is PROT_SEQ_ANN_TYPES.binding_sites) or ( pick_mode is PickMode.HMProximity and row_type is RowType.Sequence): model.handlePick(index) return aln = model.getAlignment() # This value will be checked if the user double-clicks. self._sel_prior_to_mouse_click = aln.res_selection_model.getSelection() mod = event.modifiers() if (self._drag_possible and index.data(CustomRole.ResSelected) and mod == Qt.NoModifier): self._drag_anchor_x = pos.x() self._drag_state = _DragAndDropState.MouseDown self.setCursor(Qt.ClosedHandCursor) # TODO: draw rectangle (on a delay?) elif mod & int(Qt.ControlModifier | Qt.ShiftModifier): # If Ctrl or Shift are down, then we want to modify the current # selection instead of the main selection while the mouse is down in # case this mouse press starts a drag selection. See # viewmodel.SequenceAlignmentModel.setResRangeSelectionState and # gui_alignment.ResidueSelectionModel.setCurrentSelectionState # for more information about the current selection. self._current_selection_mode = True elif event.button() == Qt.MidButton: residue = self.model().data(index, CustomRole.Residue) self.residueMiddleClicked.emit(residue) super().mousePressEvent(event)
[docs] def mouseReleaseEvent(self, event): # See Qt documentation for method documentation super().mouseReleaseEvent(event) # We must wait until after the super() call to update _drag_state since # selectionCommand needs to know if this press started a drag-and-drop # or not. pos = event.pos() index = self.indexAt(pos) aln = self.model().getAlignment() if self._drag_state is _DragAndDropState.Dragging: self._undo_stack.endMacro() self._stopAutoScroll() if self._drag_state in (_DragAndDropState.MouseDown, _DragAndDropState.Dragging): self._drag_anchor_x = None self._drag_state = None self._drag_failed_moves = 0 self.unsetCursor() index = self.indexAt(event.pos()) self._updateDragAndDropCursor(index) else: # Finish the current selection. If we weren't in current selection # mode, then these lines will have no effect. self._current_selection_mode = False aln.res_selection_model.finishCurrentSelection()
[docs] def mouseMoveEvent(self, event): # See Qt documentation for method documentation pos = event.pos() index = self.indexAt(pos) self._updateDragAndDropOnMouseMove(event, pos, index) if index.isValid(): index_id = (index.row(), index.column(), index.internalId()) if index_id != self._hovered_index: residue = self.model().data(index, CustomRole.Residue) self.residueHovered.emit(residue) self._hovered_index = index_id else: self._unhoverResidue() if self._drag_state is None: return super().mouseMoveEvent(event)
[docs] def mouseDoubleClickEvent(self, event): is_shift = event.modifiers() & Qt.ShiftModifier if is_shift: # Shift+double-click interferes w/ single-click so we ignore it. return pos = event.pos() index = self.indexAt(pos) if index.isValid(): row_type = index.data(CustomRole.RowType) res = index.data(CustomRole.Residue) modifier = event.modifiers() aln = self.model().getAlignment() if row_type is PROT_SEQ_ANN_TYPES.secondary_structure: if res and res.is_gap: return func = aln.setSecondaryStructureSelectionState elif row_type is RowType.Sequence: func = aln.setRunSelectionState elif row_type is PROT_ALN_ANN_TYPES.indices: return else: return self._doDoubleClickSelectOrDeselect(res, modifier, func)
# Note that we don't call super().mouseDoubleClickEvent(event) # because it unintentionally further changes the selection. def _doDoubleClickSelectOrDeselect(self, res, modifier, func): """ Select or deselect residues based on a double-click event. :param res: Residue that was double clicked on. :type res: residue.Residue :param modifier: The double click modifier used. Should be one of Qt.NoModifier or Qt.ControlModifier :type modifier: int :param func: Function to call for updating selection. Should take a residue.Residue as a parameter as well as a 'select' keyword argument that takes a bool value to indicate whether residues should be selected (True) or deselected (False). :type func: callable """ is_ctrl = modifier & Qt.ControlModifier aln = self.model().getAlignment() if is_ctrl: # If clicked run is already selected, deselect. # otherwise add run to current selection. orig_sel = self._sel_prior_to_mouse_click self._sel_prior_to_mouse_click = None seq = res.sequence run_idx = seq.getRun(res) run_sel = all(seq[i] in orig_sel for i in run_idx) select = not run_sel else: # clear prior selection and select based on clicked residue aln.res_selection_model.clearSelection() select = True func(res, select=select)
[docs] def wheelEvent(self, event): # See Qt documentation for method documentation super().wheelEvent(event) index = self.indexAt(event.pos()) self._updateDragAndDropCursor(index)
[docs] def event(self, e): # See Qt documentation for method documentation if e.type() == QtCore.QEvent.Leave: self._unhoverResidue() return super().event(e)
def _unhoverResidue(self): self.residueUnhovered.emit() self._hovered_index = None def _updateDragAndDropOnMouseMove(self, event, pos, index): """ Handle drag and drop changes required when the user moves the mouse cursor. This method will: - update the mouse cursor if the user is over a draggable selection - start drag and drop if the user has just moved the cursor after a mouse press - move residues if the user moves the cursor during a drag and drop :param event: The mouse move event :type event: QtGui.QMouseEvent :param pos: The cursor location in local coordinates. :type pos: QtCore.QPoint :param index: The index that the mouse cursor is currently over. :type index: QtCore.QModelIndex """ if event.buttons() == Qt.NoButton: modifiers_pressed = event.modifiers() != Qt.NoModifier self._updateDragAndDropCursor(index, modifiers_pressed) elif self._drag_state is _DragAndDropState.MouseDown: horiz_movement = abs(self._drag_anchor_x - pos.x()) start_drag_dist = QtWidgets.QApplication.startDragDistance() if horiz_movement > start_drag_dist: # start drag-and-drop once the cursor has moved far enough self._drag_state = _DragAndDropState.Dragging self._undo_stack.beginMacro("Drag and drop residues") aln = self.model().getAlignment() aln.expandSelectionToRectangle() if self._drag_state == _DragAndDropState.Dragging: x = pos.x() self._startAutoScrollIfNeeded(x) self._dragResidues(x) def _dragResidues(self, mouse_x): """ Potentially move residues in response to the user moving the cursor during drag and drop. :param mouse_x: The current x-coordinate of the mouse cursor. :type mouse_x: int """ if not self.model().rowWrapEnabled(): # If row wrapping is turned off, then don't allow dragging off the # visible portion of the view. (The user can auto-scroll instead.) if mouse_x < 0: mouse_x = 0 elif mouse_x > self.width(): mouse_x = self.width() drag_dist = mouse_x - self._drag_anchor_x # We intentionally use an int cast here instead of integer division # so that values are rounded towards zero instead of down col_movement = int(drag_dist / self._col_width) if col_movement == 0: # The user hasn't dragged a full column width, so we don't need to # do anything return self._drag_anchor_x += col_movement * self._col_width if self._drag_failed_moves: # if drag-and-drop can't move residues (because of a lack of gaps or # anchoring), then make sure that we don't try to move residues # again until the user has moved the mouse cursor back over the # dragged residues. if (col_movement > 0) == (self._drag_failed_moves > 0): # We're moving in the same direction as the last failure, so we # know that this move will fail as well. self._drag_failed_moves += col_movement col_movement = 0 else: # we're moving in a different direction than the last failure if abs(self._drag_failed_moves) >= abs(col_movement): # we still haven't moved the mouse back to where we # started failing self._drag_failed_moves += col_movement col_movement = 0 else: col_movement += self._drag_failed_moves self._drag_failed_moves = 0 if col_movement == 0: return aln = self.model().getAlignment() if col_movement > 0: success_count = aln.moveSelectionToRight(col_movement) else: success_count = -aln.moveSelectionToLeft(-col_movement) failed = col_movement - success_count self._drag_failed_moves = failed def _updateDragAndDropPossible(self): """ Figure out whether drag and drop is currently possible and update the mouse cursor (open hand if it's over a drag-and-droppable selection, or a normal pointer otherwise). """ self._drag_possible = (self._edit_mode and not self.model().alnSetResSelected() and self.model().isSingleBlockSelected()) if QtWidgets.QApplication.mouseButtons() == Qt.NoButton: self._updateDragAndDropCursor(None) def _updateDragAndDropCursor(self, index, modifiers_pressed=None): """ If the mouse cursor is currently over a drag-and-droppable selection, change the cursor to an open hand. Otherwise, reset it back to a normal cursor. :param index: The index that the mouse cursor is over, or None. If None, the index will be determined if it is required. Note that if this method is called from a mouse event handler, `event.pos()` should be used to fetch an index and that index should be given here instead of passing `None`. :type index: QtCore.QModelIndex or None :param modifiers_pressed: Whether any modifier keys are currently pressed on the keyboard. :type modifiers_pressed: bool """ if (not modifiers_pressed and self._drag_possible and self._drag_state is None): if index is None: cursor_pos = self.mapFromGlobal(QtGui.QCursor.pos()) index = self.indexAt(cursor_pos) if index.data(CustomRole.ResSelected): self._drag_state = _DragAndDropState.Hover self.setCursor(Qt.OpenHandCursor) elif self._drag_state is _DragAndDropState.Hover: self.unsetCursor() self._drag_state = None def _startAutoScrollIfNeeded(self, mouse_x): """ Start auto-scroll (i.e. scrolling while in drag-and-drop mode when the mouse cursor is close to the edges of the view) if the specified position is close enough to the view margins. :param mouse_x: The x-coordinate of the cursor location in local coordinates. :type mouse_x: int """ if self.model().rowWrapEnabled(): # If row wrapping is turned on, then there's nothing to scroll to return margin = self.autoScrollMargin() if mouse_x < margin or mouse_x > self.width() - margin: # The 150 value is taken from # QAbstractItemViewPrivate::startAutoScroll self._auto_scroll_timer.start(150) self._auto_scroll_count = 0 def _stopAutoScroll(self): self._auto_scroll_timer.stop() self._auto_scroll_count = 0 def _doAutoScroll(self): """ If the cursor is currently close to the view margins, auto-scroll the view. Activated during drag-and-drop. Note that this implementation is based on QAbstractItemView::doAutoScroll. """ scroll = self.horizontalScrollBar() # auto-scroll gradually accelerates up to a max of a page at a time if self._auto_scroll_count < scroll.pageStep(): self._auto_scroll_count += 1 cursor_pos = QtGui.QCursor.pos() viewport_pos = self.viewport().mapFromGlobal(cursor_pos) viewport_x = viewport_pos.x() margin = self.autoScrollMargin() if viewport_x < margin: scroll.setValue(scroll.value() - self._auto_scroll_count) self._drag_anchor_x += self._auto_scroll_count elif viewport_x > self.width() - margin: scroll.setValue(scroll.value() + self._auto_scroll_count) self._drag_anchor_x -= self._auto_scroll_count else: self._stopAutoScroll() return view_pos = self.mapFromGlobal(cursor_pos) view_x = view_pos.x() self._dragResidues(view_x)
[docs] def selectionCommand(self, index, event=None): """ Customize selection behavior. Filter right button mouse events to prevent selection changes when showing context menu on already-selected cells. Filter middle button clicks to prevent any selection changes. Make clicks on the ruler select the column. If a mouse press might potentially start a drag and drop operation, wait until mouse release to change the selection. See Qt documentation for argument documentation. """ if self.model().getPickingMode() in (PickMode.HMBindingSite, PickMode.HMProximity): return QtCore.QItemSelectionModel.NoUpdate if index.isValid(): row_cache_key = (index.row(), index.internalId()) row_type, *_ = self._per_row_data_cache[row_cache_key] else: row_type = None is_mouse = isinstance(event, QtGui.QMouseEvent) res_selected = self.model().data(index, CustomRole.ResSelected) if is_mouse: if event.button() == Qt.RightButton: # Qt doesn't create context menus for right-clicks with # modifiers so we do it ourselves here. self._showContextMenu(event.pos()) sel_model = self.model().getAlignment().res_selection_model if not sel_model.hasSelection(): return QtCore.QItemSelectionModel.Select elif event.modifiers() == Qt.ShiftModifier: return QtCore.QItemSelectionModel.SelectCurrent elif event.modifiers() == Qt.ControlModifier: return QtCore.QItemSelectionModel.Select elif (row_type is PROT_ALN_ANN_TYPES.indices and event.button() == Qt.RightButton and self.model().data(index, CustomRole.ColSelected)): return QtCore.QItemSelectionModel.NoUpdate elif res_selected: return QtCore.QItemSelectionModel.NoUpdate elif event.button() == Qt.MidButton: return QtCore.QItemSelectionModel.NoUpdate elif (res_selected and self._drag_possible and event.type() == event.MouseButtonPress and event.modifiers() == Qt.NoModifier): # This click might start a drag and drop return QtCore.QItemSelectionModel.NoUpdate elif event.type() == event.MouseButtonRelease: # We're releasing from a press that could've started a drag and # drop. (selectionCommand will only be called on mouse release # if it returned NoUpdate on the press.) if self._drag_state is _DragAndDropState.MouseDown: # The press didn't actually start a drag and drop, so update # the selection as if this was a normal click return QtCore.QItemSelectionModel.ClearAndSelect elif self._drag_state is _DragAndDropState.Dragging: # The press started a drag and drop, so don't update the # selection at all return QtCore.QItemSelectionModel.NoUpdate flags = super().selectionCommand(index, event) if is_mouse and row_type is PROT_ALN_ANN_TYPES.indices: return flags | QtCore.QItemSelectionModel.Columns return flags
def _updateCellSize(self, *, update_row_wrap=True): """ Set the appropriate width for all columns based on what row types are currently shown. :param update_row_wrap: Whether to update row wrapping after updating the cell size. If `False`, `_updateRowWrapping` must be called after this method has completed. :type update_row_wrap: bool """ col_width, text_height = self._colWidthAndTextHeight() self._col_width = col_width self._heights_by_row_type = { row_type: row_del.rowHeight(text_height) for row_type, row_del in self._row_delegates_by_row_type.items() } self.header().setDefaultSectionSize(self._col_width) self._per_cell_data_cache.clear() self._per_row_data_cache.clear() for row_delegate in self._row_delegates: row_delegate.clearCache() if update_row_wrap: self._updateRowWrapping() # items layout is necessary to update row height when the font size # changes self.scheduleDelayedItemsLayout() def _colWidthAndTextHeight(self): """ Determine the appropriate column width and text height for the current model settings. :return: A tuple of: - The column width in pixels - The text height in pixels. This value can be used to determine row heights using AbstractDelegate.rowHeight. :rtype: tuple(int, int) """ model = self.model() if model is None: font = self.font() else: font = model.getFont() font_metrics = QtGui.QFontMetrics(font) if (model is not None and model.getResidueDisplayMode() is ResidueFormat.ThreeLetter): # A sample three letter residue name text = "WOI" else: # a sample one-letter residue name. We intentionally pick a # wide letter to make sure that the columns are wide enough. text = "W" # We use 1.3 and 7 to add a small bit of padding for the letters col_width = int(font_metrics.boundingRect(text).width() * 1.3) text_height = font_metrics.tightBoundingRect("W").height() + 7 return col_width, text_height
[docs] def sizeHintForColumn(self, col): # See QTreeView documentation for method documentation return self._col_width
[docs] def sizeHintForRow(self, row): """ Get the height of a top-level row. Note that you probably want to use `indexRowSizeHint` instead of this method, as that method can determine the height of any row, not just a top-level one. This is still called by QTreeView::scrollContentsBy, though, so we reimplement it here to avoid the delay that would be caused by QTreeView::sizeHintForRow, which calls delegate.sizeHint for every cell in the row. See QAbstractItemView documentation for additional method documentation. """ row_type = self._per_row_data_cache[row, TOP_LEVEL][0] return self._heights_by_row_type[row_type]
[docs] def indexRowSizeHint(self, index): """ Get the height of a row. This method is not normally virtual in QTreeView, but we're using a modified version of Qt so that we can override this method. The default implementation calls delegate.sizeHint for every column in the row, which leads to major delays while scrolling. :note: This method can be called with indices from `self.model()` or from a `BaseFixedColumnsView`'s model. (See `BaseFixedColumnsView.indexRowSizeHint`.) Index row and internal ID will be the same regardless of model, but this method must not assume that `index.model() == self.model()`. See QTreeView documentation for additional method documentation. """ row = index.row() internal_id = index.internalId() row_type, per_row_data, _, _ = self._per_row_data_cache[row, internal_id] row_height = self._heights_by_row_type[row_type] try: row_height_scale = per_row_data[CustomRole.RowHeightScale] row_height *= row_height_scale except (KeyError, TypeError): # If the row doesn't have row height scale data, then leave it # unscaled. pass return row_height
[docs] def sizeHintForIndex(self, index): # See Qt documentation for method documentation height = self.indexRowSizeHint(index) return QtCore.QSize(self._col_width, height)
def _populatePerRowCache(self, row, internal_id): """ Fetch data for the specified `row` and `internal_id`. This method should only be called by `self._per_row_data_cache.__missing__`. Otherwise, row data should be retrieved by using `self._per_row_data_cache`. :param row: The row number of the row to fetch data for. :type row: int :param internal_id: The internal ID of the row to fetch data for. (Our models ensure that all indices in the same row have the same internal ID. This is not guaranteed to be generally true to all `QAbstractItemModel`.) :type internal_id: int :return: A tuple of: - The row type of the specified row. (For sequence or spacer rows, this is a `RowType` enum. For annotation rows this is an `AnnotationType` enum.) - The per-row data fetched from the model. - The row delegate for the row type. - The per-cell roles for the row type. :rtype: tuple(enum.Enum, dict(int, object), row_delegates.AbstractDelegate, set(int)) """ model = self.model() index = model.createIndex(row, 0, internal_id) per_row_data = model.data(index, CustomRole.MultipleRoles, row_delegates.PER_ROW_PAINT_ROLES) # If the dictionary is empty, then it means that we're trying to get the # size hint for a row that's in the process of being inserted or # removed. In that case, just treat it as a spacer for now and # everything will be updated once the insertion/removal is complete. row_type = per_row_data.get(CustomRole.RowType, viewconstants.RowType.Spacer) row_del = self._row_delegates_by_row_type[row_type] per_cell_roles = row_del.PER_CELL_PAINT_ROLES # get rid of data for any per_cell_roles for role in (row_delegates.POSSIBLE_PER_ROW_PAINT_ROLES & per_cell_roles): per_row_data.pop(role, None) return row_type, per_row_data, row_del, per_cell_roles
[docs] def setMenuModel(self, model): self._res_context_menu.setModel(model) self._anno_context_menu.setModel(model) self._gap_context_menu.setModel(model)
def _initContextMenus(self): """ Set up residue and gap context menus """ self._res_context_menu = ResidueContextMenu(self) self._anno_context_menu = AnnoResContextMenu(self) for menu in (self._res_context_menu, self._anno_context_menu): res_ss = ( (menu.expandBetweenGapsRequested, self.expandSelectionBetweenGaps), (menu.expandAlongColumnsRequested, self.expandSelectionAlongColumns), (menu.expandFromReferenceRequested, self.expandSelectionFromReference), (menu.expandToFullChainRequested, self.expandSelectionToFullChain), (menu.invertSelectionRequested, self.invertResSelection), (menu.copyToNewTabRequested, self.copyToNewTabRequested), (menu.searchForMatchesRequested, self.searchForMatches), (menu.copyRequested, self.copySelection), (menu.deleteRequested, self.deleteSelection), (menu.deleteGapsOnlyRequested, self.deleteSelectedGaps), (menu.replaceWithGapsRequested, self.replaceSelectionWithGaps), (menu.highlightSelectionRequested, self.openColorPanelRequested), (menu.removeHighlightsRequested, self.clearSelectedHighlights), # TODO: MSV-1994 (menu.hideColumnsRequested, self.hideColumns), (menu.anchorRequested, self.anchorSelection), (menu.unanchorRequested, self.unanchorSelection), (menu.editSeqInPlaceRequested, self.sequenceEditModeRequested), ) # yapf: disable for signal, slot in res_ss: signal.connect(slot) self._anno_context_menu.expandToAnnoValsRequested.connect( self.expandSelectionToAnnoVals) menu = GapContextMenu(self) gap_ss = ( (menu.expandAlongGapsRequested, self.expandSelectionAlongGaps), (menu.deselectGapsRequested, self.deselectGaps), (menu.expandAlongColumnsRequested, self.expandSelectionAlongColumns), (menu.expandFromReferenceRequested, self.expandSelectionFromReference), (menu.expandToFullChainRequested, self.expandSelectionToFullChain), (menu.invertSelectionRequested, self.invertResSelection), (menu.copyToNewTabRequested, self.copyToNewTabRequested), (menu.deleteGapsOnlyRequested, self.deleteSelectedGaps), (menu.copyRequested, self.copySelection), (menu.highlightSelectionRequested, self.openColorPanelRequested), (menu.removeHighlightsRequested, self.clearSelectedHighlights), # TODO: MSV-1994 (menu.hideColumnsRequested, self.hideColumns), (menu.anchorRequested, self.anchorSelection), (menu.unanchorRequested, self.unanchorSelection), (menu.editSeqInPlaceRequested, self.sequenceEditModeRequested), ) # yapf: disable for signal, slot in gap_ss: signal.connect(slot) self._gap_context_menu = menu
[docs] def contextMenuEvent(self, event): if self.model().rowCount(): self._showContextMenu(event.pos()) else: super().contextMenuEvent(event)
def _showContextMenu(self, pos): clicked_index = self.indexAt(pos) clicked_residue = clicked_index.data(CustomRole.Residue) if clicked_residue is None: # terminal gap return row_type = clicked_index.data(CustomRole.RowType) if clicked_residue.is_gap: context_menu = self._gap_context_menu elif row_type in PROT_SEQ_ANN_TYPES: context_menu = self._anno_context_menu ann_index = clicked_index.data(CustomRole.MultiRowAnnIndex) context_menu.setClickedAnno(row_type, ann_index) else: context_menu = self._res_context_menu aln = self.model().getAlignment() enable_expand_from_ref = self._shouldEnableExpandFromReference() context_menu.setExpandFromReferenceEnabled(enable_expand_from_ref) clicked_residue_is_anchored = clicked_index.data(CustomRole.ResAnchored) context_menu.setAnchorEnabled(clicked_residue_is_anchored or aln.anchorResidueValid(clicked_residue)) context_menu.setAnchorMode(not clicked_residue_is_anchored) context_menu.popup(self.mapToGlobal(pos)) def _shouldEnableExpandFromReference(self): """ The "Expand From Reference" menu item should be enabled if a reference residue is included in the selection and its column isn't already selected. """ aln = self.model().getAlignment() ref_seq = aln.getReferenceSeq() res_selection_model = aln.res_selection_model sel_residues = res_selection_model.getSelection() for selected_residue in sel_residues: if selected_residue.sequence == ref_seq: column = aln.getColumn(selected_residue.idx_in_seq) if not all(res in sel_residues for res in column): return True else: return False
[docs] def expandSelectionBetweenGaps(self): """ Expand selected residues between gaps within each sequence. """ self.model().getAlignment().expandSelectionAlongSequences(True)
[docs] def expandSelectionAlongGaps(self): """ Expand selected gaps along gaps within each sequence. """ self.model().getAlignment().expandSelectionAlongSequences(False)
[docs] def deselectGaps(self): """ Deselect selected gaps """ self.model().getAlignment().deselectGaps()
[docs] def expandSelectionToAnnoVals(self, anno, ann_index): self.model().expandSelectionToAnnotationValues(anno, ann_index)
[docs] def expandSelectionAlongColumns(self): """ Expand selection along the columns of selected elements. """ self.model().getAlignment().expandSelectionAlongColumns()
[docs] def expandSelectionFromReference(self): """ Expand selection along columns of selected reference elements """ self.model().getAlignment().expandSelectionFromReference()
[docs] def expandSelectionToFullChain(self): self.model().getAlignment().expandSelectionToFullChain()
[docs] def invertResSelection(self): """ Invert selection of residues in the alignment """ self.model().getAlignment().invertResSelection()
[docs] def searchForMatches(self): """ Searches for matches for the selected residues. This should only be called if the selection is a single block in a single sequence. """ aln = self.model().getAlignment() pattern = self.model().getSingleLetterCodeForSelectedResidues() # TODO MSV-2041: Move this logic to selectPattern in alignment.py matching_residues = aln.findPattern(pattern) if not matching_residues: return with command.compress_command(aln.undo_stack, "Select Matches"): aln.res_selection_model.clearSelection() aln.res_selection_model.setSelectionState(matching_residues, True)
[docs] def replaceSelectionWithGaps(self): """ Replace selected with gaps. """ aln = self.model().getAlignment() selection = aln.res_selection_model.getSelection() if len(selection.intersection(aln.getAnchoredResiduesWithRef())) > 0: response = QtWidgets.QMessageBox.question( self, "Some anchors will be removed", "Some anchors will be removed. Continue anyway?") if response != QtWidgets.QMessageBox.Yes: return undo_desc = f'Replace {len(selection)} Residues with Gaps' with command.compress_command(aln.undo_stack, undo_desc): aln.removeAnchors(selection) aln.replaceResiduesWithGaps(selection)
[docs] def clearSelectedHighlights(self): """ Clear the highlights of the selection. """ self.model().getAlignment().setSelectedResColor(None)
[docs] def anchorSelection(self): """ Respond to an "Anchor" event selected through the residue context menu. Anchor selected residues using the following per-column protocol: 1) If only reference residues are selected, anchor all non-reference residues aligned to those reference residues. 2) If any non-reference residues (or a mix of reference and non-reference residues) are selected, anchor all selected non-reference residues. 3) Any selected elements that can't be anchored (due to being gaps or being in columns with reference gaps) will be ignored. """ aln = self.model().getAlignment() ref_seq = aln.getReferenceSeq() sel_residues = aln.res_selection_model.getSelection() processed_sel_residues = [] for res in sel_residues: beyond_ref_seq = (res.idx_in_seq >= len(ref_seq)) if beyond_ref_seq or ref_seq[res.idx_in_seq].is_gap: # Ignore gaps and elements in columns with reference gaps continue processed_sel_residues.append(res) aln.anchorResidues(processed_sel_residues)
[docs] def unanchorSelection(self): """ Test that the view has correct behavior for residue unanchoring: 1) If only non-reference residues are selected, unanchor them. 2) If any reference residues (or a mix of reference and non-reference residues) are selected, unanchor all non-reference residues aligned to the selected reference residues. """ aln = self.model().getAlignment() sel_residues = aln.res_selection_model.getSelection() aln.removeAnchors(sel_residues)
[docs] def resizeEvent(self, event): """ Update the row wrapping whenever the table is resized See Qt documentation for argument documentation """ super().resizeEvent(event) self._update_row_wrap_timer.start()
def _updateRowWrapping(self, force=False): """ Alert the model if the number of visible columns has changed, as the model will need to update row wrapping. :param force: If False, the current number of columns will only be sent to the model if the number has changed since the last update. If True, the current number of columns will be sent no matter what. :type force: bool """ if self._col_width == 0: # this may happen when the table is in the process of being # displayed return 0 table_width = self.viewport().width() num_cols = table_width // self._col_width if force or num_cols != self._num_cols_visible: model = self.model() model.tableWidthChanged(num_cols) self._num_cols_visible = num_cols
[docs] def paintEvent(self, event): """ We override this method to speed up painting. We calculate what columns need to be painted and the x-coordinates of their left edges, which get used in `drawRow` below. See Qt documentation for additional method documentation. """ # calculate values to be used in drawRow header = self.header() rect = event.region().boundingRect() # visualIndexAt returns -1 if the coordinate is not over a column first_visual_col = header.visualIndexAt(rect.left()) last_visual_col = header.visualIndexAt(rect.right()) if first_visual_col < 0: first_visual_col = 0 if last_visual_col < 0: last_visual_col = header.count() - 1 self._cols_to_paint = list(range(first_visual_col, last_visual_col + 1)) self._col_left_edges = list( map(self.columnViewportPosition, self._cols_to_paint)) if self._cols_to_paint: super().paintEvent(event) self._cols_to_paint = None self._col_left_edges = None
[docs] def drawRow(self, painter, option, index): """ This view paints entire rows at once instead of painting each cell separately. This gives us a ~6x improvement in scrolling frame rate (assuming data is already cached). This view also fetches data for entire rows at once, which gives us an additional ~2x improvement in scrolling frame rate when data is uncached. See Qt documentation for additional method documentation. """ row_rect = option.rect row_height = row_rect.height() if row_height <= 1: # If the row_rect is only one pixel tall, then there's nothing to # paint. For example, this can happen when disulfide bonds are # enabled but one sequence has no bonds. We don't use zero pixels # since QTreeView sometimes keeps stale QModelIndex objects around # when they refer to zero height rows, which can lead to tracebacks. return # make sure that we don't modify the passed in option object model = self.model() row = index.row() # Our models use the same internal ID for all columns in a row internal_id = index.internalId() # load all data we'll need into cache per_cell_data = [] row_type, per_row_data, row_del, per_cell_roles = \ self._per_row_data_cache[row, internal_id] # uncached_cols contains the column numbers for cells that weren't found # in the cache uncached_cols = [] # uncached_i contains the per_cell_data indices for cells that weren't # found in the cache uncached_i = [] for i, col in enumerate(self._cols_to_paint): try: data = self._per_cell_data_cache[row, col, internal_id] except KeyError: data = None uncached_cols.append(col) uncached_i.append(i) per_cell_data.append(data) if uncached_cols: uncached_data = model.rowData(row, uncached_cols, internal_id, per_cell_roles) for i, col, cur_data in zip(uncached_i, uncached_cols, uncached_data): self._per_cell_data_cache[row, col, internal_id] = cur_data per_cell_data[i] = cur_data row_del.paintRow(painter, per_cell_data, per_row_data, row_rect, self._col_left_edges, row_rect.top(), self._col_width, row_height)
[docs] def setSequenceExpansionState(self, sequences, expand=True): """ Set the expansion state for the given sequences. :param sequences: Sequences to expand or collapse. :type sequences: list(Sequence) :param expand: Whether to expand the sequences :type expand: bool """ model = self.model() aln = model.getAlignment() offset = model.getNumShownGlobalAnnotations() new_expanded = [aln.index(seq) + offset for seq in sequences] for i in new_expanded: index = model.index(i, 0) self.setExpanded(index, expand)
[docs] def setConstraintsShown(self, enable): """ Enable or disable constraint display :param enable: Whether to display constraints :type enable: bool """ # tell the sequence row to start painting constraints self._row_delegates_by_row_type[RowType.Sequence].setConstraintsShown( enable) self.update()
[docs] def setLigandConstraintsShown(self, enable): self._row_delegates_by_row_type[ PROT_SEQ_ANN_TYPES.binding_sites].setConstraintsShown(enable) self.update()
[docs] def setChimeraShown(self, enable): self._row_delegates_by_row_type[RowType.Sequence].setChimeraShown( enable) self.update()
[docs] def setResOutlinesShown(self, enable): # tell the sequence row to start painting res outlines self._row_delegates_by_row_type[RowType.Sequence].setResOutlinesShown( enable) self.update()
[docs] def setEditMode(self, enable): """ Enable or disable edit mode. :param enable: Whether to enable edit mode. :type enable: bool """ self._edit_mode = enable # tell the sequence editor to start painting I-bars self._row_delegates_by_row_type[RowType.Sequence].setEditMode(enable) # clear the cache so I-bar locations will be fetched self._per_cell_data_cache.clear() self._per_row_data_cache.clear() self._updateDragAndDropPossible() self._res_context_menu.edit_seq_in_place.setChecked(enable) self._gap_context_menu.edit_seq_in_place.setChecked(enable) # force a redraw so that I-bars can be drawn self.update()
[docs] def editAndMaybeClearAnchors(self, edit_func): """ Try to perform an edit action. If it can't proceed because it would break anchors, ask the user whether they want to cancel (keeping anchors) or break anchors and proceed. :param edit_func: the function that may break anchors :type edit_func: callable """ try: edit_func() except alignment.AnchoredResidueError as exc: anchors_to_remove = exc.blocking_anchors remove_all_anchors = exc.blocking_anchors is exc.ALL_ANCHORS mbox = dialogs.EditClearAnchorsMessageBox( parent=self, remove_all=remove_all_anchors) response = mbox.exec() if response is True: desc = "Clear Anchors and Edit" with command.compress_command(self._undo_stack, desc): aln = self.model().getAlignment() if remove_all_anchors: aln.clearAnchors() else: aln.removeAnchors(anchors_to_remove) edit_func()
[docs] def keyPressEvent(self, event): # See Qt documentation for method documentation if event.key() == Qt.Key_A and event.modifiers() & Qt.ControlModifier: self.model().getAlignment().res_selection_model.selectAll() elif event.key() == Qt.Key_C and event.modifiers() & Qt.ControlModifier: self.copySelection() elif self._edit_mode and self.state() != self.EditingState: # EditingState means there's currently an editor open if event.key() == Qt.Key_Space: self.insertGapsToLeftOfSelection() elif event.key() == Qt.Key_Delete: self.deleteSelectedGaps() elif event.key() in (Qt.Key_Enter, Qt.Key_Return): self.editCurrentSelection() else: super().keyPressEvent(event) else: super().keyPressEvent(event)
[docs] def insertGapsToLeftOfSelection(self): """ Insert one gap to the left of every selected block of residues/gaps. Editing is not allowed if alignment set residues are selected. """ if self.model().alnSetResSelected(): return edit_func = self.model().getAlignment().insertGapsToLeftOfSelection self.editAndMaybeClearAnchors(edit_func)
[docs] def deleteSelectedGaps(self): """ Delete all selected gaps. Selected residues will remain unchanged. Editing is not allowed if alignment set residues are selected. """ if self.model().alnSetResSelected(): return self.model().getAlignment().deleteSelectedGaps()
[docs] def copySelection(self): """ Copies the selected residues and gaps as a string onto the clipboard. :raises ValueError: if the selection is not a single block. """ res_selection_model = self.model().getAlignment().res_selection_model if not res_selection_model.hasSelection(): return if not res_selection_model.isSingleBlockSingleSeqSelected(): self.warning( 'Copy action is limited to a single block from a single sequence.', title='Selection Not Copied', ) return selected_res = res_selection_model.getSelection() text = "".join( str(res) for res in sorted(selected_res, key=lambda r: r.idx_in_seq)) clipboard = get_application().clipboard() clipboard.setText(text)
[docs] def deleteSelection(self): """ Delete all selected residues and gaps. If more than one block of residues/gaps is selected, the user will be prompted for confirmation before the deletion occurs. """ aln = self.model().getAlignment() if not aln.res_selection_model.isSingleBlockSelected(): response = QtWidgets.QMessageBox.question( self, "Delete multiple selection blocks?", "Multiple blocks are selected. Do you wish to delete all " "selected residues?") if response != QtWidgets.QMessageBox.Yes: return self._deleteSelection()
[docs] def deleteUnselectedResidues(self): """ Delete all the unselected residues. """ self.invertResSelection() self._deleteSelection()
def _deleteSelection(self): aln = self.model().getAlignment() edit_func = aln.deleteSelection self.editAndMaybeClearAnchors(edit_func)
[docs] def editCurrentSelection(self): """ If a single residue (or gap) is selected, open an editor for replacing one residue at a time. If a single block of residues/gaps in a single sequence is selected, open an editor for overwriting the current selection. Otherwise, do nothing. Note that editing is disallowed for structured residues, on the workspace tab, or in alignment sets, so no editor will be opened in those cases. """ model = self.model() sel_model = model.getAlignment().res_selection_model if (self._is_workspace or model.alnSetResSelected() or sel_model.anyStructuredResiduesSelected()): pass elif len(sel_model.getSelection()) == 1: self.changeResidues() elif sel_model.isSingleBlockSingleSeqSelected(): self.replaceSelection()
[docs] def changeResidues(self): """ Open an editor that allows the user to replace one residue or gap at a time. This method should only be called when there is exactly one residue or gap selected. """ indices = self.model().getSelectedResIndices() if len(indices) != 1: raise RuntimeError( "Cannot change residues with more than one residue selected") index = indices[0] self.setCurrentIndex(index) self.scrollTo(index) self._edit_delegate.setEditorType(_EditorType.Change) self.edit(index)
[docs] def insertResidues(self): """ Open an editor that allows the user to insert residues before the current selection. This method should only be called when there is exactly one block of residues or gaps from a single sequence selected. """ first_sel_index, _ = self._getIndicesForSelectedBlock() self.scrollTo(first_sel_index) self._edit_delegate.setEditorType(_EditorType.Insert) self.edit(first_sel_index)
[docs] def replaceSelection(self): """ Open an editor that allows the user to overwrite the current selection. This method should only be called when there is exactly one block of residues or gaps from a single sequence selected. Note that if row wrapping is turned on, it's possible that one contiguous group of residues may be split over multiple rows of the table. The editor will only cover indices in the first row, but the newly entered sequence will replace all selected residues (and the editor will start with the entire sequence to be replaced). """ first_sel_index, indices = self._getIndicesForSelectedBlock() row = first_sel_index.row() indices = [index for index in indices if index.row() == row] num_indices = len(indices) last_index = max(indices, key=lambda index: index.column()) # scroll so as much of the selection as possible is visible self.scrollTo(last_index) self.scrollTo(first_sel_index) self._edit_delegate.setEditorType(_EditorType.Replace, num_indices) self.edit(first_sel_index)
def _getIndicesForSelectedBlock(self): """ Return indices for the currently selected block. :raise RuntimeError: If no indices are selected. :return: A tuple of: - The first selected index (top-most row, left-most column) - A list of all selected indices :rtype: tuple(QtCore.QModelIndex, list[QtCore.QModelIndex]) """ indices = self.model().getSelectedResIndices() if not indices: raise RuntimeError("No residues selected") first_sel_index = min(indices, key=lambda index: (index.row(), index.column())) return first_sel_index, indices def _moveChangeEditor(self, direction): """ Move the change residue editor one index in the specified direction. :param direction: The direction to move the editor. :type direction: Adjacent """ cur_index = self.currentIndex() new_index = self.model().getAdjacentIndexForEditing( cur_index, direction) if new_index.isValid(): sel_model = self.selectionModel() self.setCurrentIndex(new_index) sel_model.select(new_index, sel_model.ClearAndSelect) self.scrollTo(new_index) # without the timer, the new editor opens without its contents # selected QtCore.QTimer.singleShot(0, partial(self.edit, new_index)) def _clearCachesOnModelChange(self): self._per_row_data_cache.clear() self._per_cell_data_cache.clear() self._update_drag_and_drop_possible_timer.start() def _onResidueSelectionChanged(self): self._per_cell_data_cache.clear() self._updateDragAndDropPossible() # since residueSelectionChanged doesn't trigger dataChanged, we have to # manually trigger a repaint if self.isVisible(): self.viewport().update()
[docs] def setSelection(self, rect, flags): # See Qt documentation for method documentation # QAbstractItemView::mouseMoveEvent calls this method with a non- # normalized QRect, so rect.topLeft() isn't necessarily the top-left # corner of the rect. Instead, rect.topLeft() is always where the mouse # press happened and rect.bottomRight() is always the current mouse # location. # We force a selection update whenever we change the selection # (`.forceSelectionUpdate`). Without doing this, the # structure model will be unable to keep selection synchronized when # two or more sequences in the same tab are linked to the same chain. from_index = self.indexAt(rect.topLeft()) to_index = self.indexAt(rect.bottomRight()) QISM = QtCore.QItemSelectionModel aln = self.model().getAlignment() rsm = aln.res_selection_model if flags & QISM.Clear: self.model().clearResSelection() rsm.forceSelectionUpdate() if flags & QISM.Select: selection_state = True elif flags & QISM.Deselect: selection_state = False elif flags & QISM.Toggle: selected = self.model().data(from_index, CustomRole.ResSelected) selection_state = not selected else: return columns = flags & QISM.Columns self.model().setResRangeSelectionState( from_index, to_index, selection_state, columns, current=self._current_selection_mode) rsm.forceSelectionUpdate()
[docs]class ProteinAlignmentView(AbstractAlignmentView): pass
[docs]class NoScrollAlignmentView(AbstractAlignmentView): """ Alignment view without scroll bars, used in saving the entire alignment as an image. """
[docs] def __init__(self, parent=None): super().__init__(command.UndoStack(), parent=parent) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
[docs] def setModel(self, model): # See parent class for method documentation super().setModel(model) width = self.header().length() height = 0 for cur_index in self._traverseAllRows(): height += self.indexRowSizeHint(cur_index) self.resize(QtCore.QSize(width, height))
def _traverseAllRows(self): """ Traverse all model rows and children :return: All row indices :rtype: generator(QtCore.QModelIndex) """ yield from qt_utils.traverse_model_rows(self.model())
[docs]class LogoAlignmentView(NoScrollAlignmentView): """ Alignment view meant to draw ExportLogoProxyModels. """
[docs] def drawRow(self, painter, option, index): """ Draw the row corresponding to the given index. This logic is based off of AbstractAlignmentView.drawRow. However, because the view is static, no caching is used. """ row_rect = option.rect row_height = row_rect.height() if row_height <= 1: return row_type, per_row_data, row_del, per_cell_roles = \ self._per_row_data_cache[index.row(), index.internalId()] per_cell_data = self.model().rowData(index, self._cols_to_paint, per_cell_roles) row_del.paintRow(painter, per_cell_data, per_row_data, row_rect, self._col_left_edges, row_rect.top(), self._col_width, row_height)
[docs]class BaseFixedColumnsView(SeqExpansionViewMixin, CtrlClickToggleMixin, QtWidgets.QTreeView): """ Class for fixed column views to be shown alongside an alignment view. :cvar CACHE_SIGNALS: Model signals for caching callbacks :vartype CACHE_SIGNALS: list(str) """ SELECTION_ROLE = CustomRole.SeqSelected CACHE_SIGNALS = [ "rowsInserted", "rowsRemoved", "rowsMoved", "columnsInserted", "columnsRemoved", "columnsMoved", "modelReset", "layoutChanged", "dataChanged", "textSizeChanged", "rowHeightChanged" ]
[docs] def __init__(self, alignment_view, parent=None): """ :param alignment_view: The main alignment view :type alignment_view: `ProteinAlignmentView` """ super(BaseFixedColumnsView, self).__init__(parent) self.alignment_view = alignment_view self.setFrameShape(QtWidgets.QFrame.NoFrame) self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) self.setFocusPolicy(Qt.NoFocus) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.verticalScrollBar().hide() self.alignment_view.verticalScrollBar().valueChanged.connect( self.verticalScrollBar().setValue) self.verticalScrollBar().valueChanged.connect( self.alignment_view.verticalScrollBar().setValue) header = self.header() header.setSectionResizeMode(QtWidgets.QHeaderView.Fixed) header.setStretchLastSection(False) header.setMinimumSectionSize(1) header.hide() self._row_del = row_delegates.FixedColumnsDelegate() self._data_cache = table_speed_up.DataCache(5000) # The following variables are used to pass information from paintEvent # to drawRow since TreeView::paintEvent does the actual calling of # drawRow self._cols_to_paint = None self._col_left_edges = None self._col_widths = None self._selectable_cols = None self._selection_rect = QtCore.QRect() self._drop_indicator_y = None self._selection_xs = None
[docs] def sizeHint(self): # See Qt documentation for method documentation. view_width = self.getSizeHint() hint = super(BaseFixedColumnsView, self).sizeHint() hint.setWidth(view_width) return hint
[docs] def minimumSizeHint(self): return self.sizeHint()
[docs] def setModel(self, model): """ Connects signals from model with the view. See Qt documentation for argument documentation """ old_model = self.model() if old_model is not None: old_model.columnsInserted.disconnect(self.updateGeometry) old_model.columnsRemoved.disconnect(self.updateGeometry) old_model.modelReset.disconnect(self.updateGeometry) old_model.textSizeChanged.disconnect( self.scheduleDelayedItemsLayout) old_model.rowHeightChanged.disconnect( self.scheduleDelayedItemsLayout) for signal_name in self.CACHE_SIGNALS: getattr(old_model, signal_name).disconnect(self.clearCache) super(BaseFixedColumnsView, self).setModel(model) if model is not None: model.columnsInserted.connect(self.updateGeometry) model.columnsRemoved.connect(self.updateGeometry) model.modelReset.connect(self.updateGeometry) model.textSizeChanged.connect(self.scheduleDelayedItemsLayout) model.rowHeightChanged.connect(self.scheduleDelayedItemsLayout) for signal_name in self.CACHE_SIGNALS: getattr(model, signal_name).connect(self.clearCache) selectable_cols = model.selectableColumns() if not selectable_cols: selectable_cols = None self._selectable_cols = selectable_cols
[docs] def setLightMode(self, enabled): """ Sets light mode on the delegate """ self._row_del.setLightMode(enabled)
[docs] def indexRowSizeHint(self, index): """ See `AbstractAlignmentView.indexRowSizeHint` documentation for method documentation. """ return self.alignment_view.indexRowSizeHint(index)
[docs] def sizeHintForRow(self, row): """ See `AbstractAlignmentView.sizeHintForRow` documentation for method documentation. """ return self.alignment_view.sizeHintForRow(row)
def __copy__(self): copy_view = self.__class__(self.alignment_view, parent=self.parent()) copy_view.setModel(self.model()) return copy_view @contextmanager def _paintingColumns(self, first_visual_col, last_visual_col): """ A context manager for painting. :param first_visual_col: The left-most column to paint :type first_visual_col: int :param last_visual_col: The right-most column to paint :type last_visual_col: int """ try: self._cols_to_paint = list( range(first_visual_col, last_visual_col + 1)) self._col_left_edges = list( map(self.columnViewportPosition, self._cols_to_paint)) self._col_widths = list(map(self.columnWidth, self._cols_to_paint)) if self._selectable_cols: # If this view paints a selection, set up the left and right # edges of self._selection_rect so we can use it to highlight # selected rows sel_left = self.columnViewportPosition(self._selectable_cols[0]) sel_right_col = self._selectable_cols[-1] sel_right = (self.columnViewportPosition(sel_right_col) + self.columnWidth(sel_right_col)) self._selection_rect.setLeft(sel_left) self._selection_rect.setRight(sel_right) # selection_xs are used when drawing the drag-and-drop drop # indicator self._selection_xs = (sel_left, sel_right) yield finally: self._cols_to_paint = None self._col_left_edges = None self._col_widths = None # we intentionally don't destroy self._selection_rect so it can be # reused for the next paint self._selection_xs = None
[docs] def paintEvent(self, event): """ We override this method to speed up painting. We calculate what columns need to be painted and various coordinates, which get used in `drawRow` below. See Qt documentation for additional method documentation. """ # calculate values to be used in drawRow header = self.header() rect = event.region().boundingRect() # visualIndexAt returns -1 if the coordinate is not over a column first_visual_col = header.visualIndexAt(rect.left()) last_visual_col = header.visualIndexAt(rect.right()) if first_visual_col < 0: first_visual_col = 0 if last_visual_col < 0: last_visual_col = header.count() - 1 if last_visual_col >= first_visual_col: with self._paintingColumns(first_visual_col, last_visual_col): super(BaseFixedColumnsView, self).paintEvent(event) if self._drop_indicator_y is not None: self._paintDropIndicator()
[docs] def drawRow(self, painter, option, index): """ This view fetches data and paints entire rows at once instead of fetching data for and painting each cell separately. This gives us a dramatic improvement in scrolling frame rate. See Qt documentation for additional method documentation. """ row_rect = option.rect row_height = row_rect.height() if row_height <= 1: # If the row_rect is only one pixel tall, then there's nothing to # paint. For example, this can happen when disulfide bonds are # enabled but one sequence has no bonds. We don't use zero pixels # since QTreeView sometimes keeps stale QModelIndex objects around # when they refer to zero height rows, which can lead to tracebacks. return # make sure that we don't modify the passed in option object model = self.model() row = index.row() # Our models use the same internal ID for all columns in a row internal_id = index.internalId() roles = self._row_del.PAINT_ROLES # even if row wrap is on, we only want the title background painted for # the first wrap is_title_row = row == 0 and internal_id == TOP_LEVEL mouse_over_row, mouse_over_struct_col = self._isMouseOverIndex(index) # all_data contains all of the data for the entire row, once {role: # value} dictionary per column all_data = [] # uncached_cols contains the column numbers for cells that weren't found # in the cache uncached_cols = [] # uncached_i contains the all_data indices for cells that weren't found # in the cache uncached_i = [] for i, col in enumerate(self._cols_to_paint): try: data = self._data_cache[row, col, internal_id] except KeyError: data = None uncached_cols.append(col) uncached_i.append(i) all_data.append(data) if uncached_cols: uncached_data = model.rowData(row, uncached_cols, internal_id, roles) for i, col, cur_data in zip(uncached_i, uncached_cols, uncached_data): self._data_cache[row, col, internal_id] = cur_data all_data[i] = cur_data row_top = row_rect.top() paint_expansion_column = self._cols_to_paint[0] == 0 self._row_del.paintRow(painter, all_data, is_title_row, self._selection_rect, row_rect, self._col_left_edges, self._col_widths, row_top, row_height, mouse_over_row, mouse_over_struct_col, paint_expansion_column) # paint the expand/collapse arrow if internal_id == TOP_LEVEL and paint_expansion_column: cell_rect = QtCore.QRect(self._col_left_edges[0], row_top, self._col_widths[0], row_height) self.drawBranches(painter, cell_rect, index)
def _isMouseOverIndex(self, index): # Additional args for paintRow for mouse-over highlighting, which is # only implemented in AlignmentInfoView so we just return Falses here return False, False
[docs] def mousePressEvent(self, event): """ Manually handle mouse press events because the internal code that expands and collapses groups was not correctly detecting the click on Windows. :param event: the mouse event that occured :type event: QtGui.QMouseEvent """ index = self.indexAt(event.pos()) if self.isGroupIndicatorIndex(index): # index is of branch indicator self.setExpanded(index, not self.isExpanded(index)) else: super().mousePressEvent(event)
[docs] def mouseReleaseEvent(self, event): """ Ignore event if release is over group expansion arrow so it doesnt negate the effect of mousePressEvent. Typically, whether expansion happens on mouse press or release is platform dependent, so by doing this we are normalizing it to only happen on mouse press. """ index = self.indexAt(event.pos()) if not self.isGroupIndicatorIndex(index): super().mouseReleaseEvent(event)
[docs] def isGroupIndicatorIndex(self, index): """ Get whether or not the given index corresponds to the cell used for the group expansion indicator """ return index.internalId() == TOP_LEVEL and index.column() == 0
def _paintDropIndicator(self): """ Paint the drag-and-drop drop indicator (the horizontal line showing where the dragged rows would be placed if they were dropped). Note that this method should only be called in a `_paintingColumns` context and when `self._drop_indicator_y` is not None. """ painter = QtGui.QPainter(self.viewport()) painter.setPen(Qt.white) left, right = self._selection_xs y = self._drop_indicator_y painter.drawLine(left, y, right, y)
[docs] def clearCache(self): self._data_cache.clear() self._row_del.clearCache()
[docs]class AnnotationContextMenu(StyledQMenu): """ Class for annotation menus in the info section of the view :ivar annotation: The right-clicked annotation type :ivar clearRequested: Signal emitted to turn off the selected annotations :ivar clearConstraintsRequested: Signal emitted to turn off pairwise constraints :ivar deleteFromAllRequested: Signal emitted to delete the selected non-toggleable annotations (e.g. pfam, predictions) """ clearRequested = QtCore.pyqtSignal() clearConstraintsRequested = QtCore.pyqtSignal() deleteFromAllRequested = QtCore.pyqtSignal() selectAssociatedResiduesRequested = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None): super().__init__(parent) self.annotation = None self._any_pfam = False self._any_predicted = False self._any_standard = False self.clear_annotation_act = self.addAction("Clear Annotations") self.clear_annotation_act.triggered.connect(self.clearRequested, Qt.QueuedConnection) self.select_associated_residues_act = self.addAction( "Select Associated Residues") self.select_associated_residues_act.triggered.connect( self.selectAssociatedResiduesRequested, Qt.QueuedConnection) self.clear_constraints_act = self.addAction("Clear Constraints") self.clear_constraints_act.triggered.connect( self.clearConstraintsRequested, Qt.QueuedConnection) self.delete_from_all_act = self.addAction("Delete from All Sequences") self.delete_from_all_act.triggered.connect(self.deleteFromAllRequested, Qt.QueuedConnection)
[docs] def setSelectedAnnotations(self, selected_annotations): any_standard = False any_predicted = False any_pfam = False for ann_info in selected_annotations: ann = ann_info.ann if ann is PROT_SEQ_ANN_TYPES.pfam: any_pfam = True elif ann in PRED_ANN_TYPES: any_predicted = True else: any_standard = True self._any_pfam = any_pfam self._any_predicted = any_predicted self._any_standard = any_standard
[docs] def popup(self, pos): constraints = self.annotation is PROT_SEQ_ANN_TYPES.pairwise_constraints binding_site_anno = self.annotation is PROT_SEQ_ANN_TYPES.binding_sites if constraints: self.clear_constraints_act.setVisible(True) self.clear_annotation_act.setVisible(False) self.delete_from_all_act.setVisible(False) else: self.clear_constraints_act.setVisible(False) self.clear_annotation_act.setVisible(self._any_standard) self.select_associated_residues_act.setVisible(binding_site_anno) can_delete = self._any_pfam or self._any_predicted self.delete_from_all_act.setVisible(can_delete) if self._any_pfam and not self._any_predicted: text = "Delete Family Sequences" elif self._any_predicted and not self._any_pfam: text = "Delete Predictions" else: text = "Delete from All Sequences" self.delete_from_all_act.setText(text) super().popup(pos)
[docs]class AlignmentSetContextMenu(StyledQMenu): selectRequested = QtCore.pyqtSignal(str) deselectRequested = QtCore.pyqtSignal(str) renameRequested = QtCore.pyqtSignal(str) dissolveRequested = QtCore.pyqtSignal(str) gatherRequested = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None): super().__init__(parent) self.set_name = None self.select_act = self.addAction("Select All in Set") self.deselect_act = self.addAction("Deselect All in Set") self.addSeparator() self.rename_act = self.addAction("Rename Set...") self.dissolve_act = self.addAction("Dissolve Set") self.addSeparator() self.gather_act = self.addAction("Gather All Sets") self.select_act.triggered.connect( lambda: self.selectRequested.emit(self.set_name), Qt.QueuedConnection) self.deselect_act.triggered.connect( lambda: self.deselectRequested.emit(self.set_name), Qt.QueuedConnection) self.rename_act.triggered.connect( lambda: self.renameRequested.emit(self.set_name), Qt.QueuedConnection) self.dissolve_act.triggered.connect( lambda: self.dissolveRequested.emit(self.set_name), Qt.QueuedConnection) self.gather_act.triggered.connect(self.gatherRequested, Qt.QueuedConnection)
[docs]class AlignmentMetricsView(BaseFixedColumnsView): """ View for the fixed columns to the right of the alignment that includes sequence identity and homology. """ sortRequested = QtCore.pyqtSignal(object, bool)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.context_menu = None self.setRootIsDecorated(False) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) width = self.fontMetrics().width('W') * 5 self.header().setDefaultSectionSize(int(width)) self.context_menu = AlignmentMetricsContextMenu(parent=self) self.context_menu.sortRequested.connect(self.sortRequested)
[docs] def setModel(self, model): """ Connects signals from model with the view. See Qt documentation for argument documentation """ old_model = self.model() if old_model is not None: old_model.rowsInserted.disconnect(self._onRowsInserted) old_model.rowsRemoved.disconnect(self._onRowsRemoved) super().setModel(model) if model is not None: model.rowsInserted.connect(self._onRowsInserted) model.rowsRemoved.connect(self._onRowsRemoved)
def _onRowsInserted(self, parent_index, first, last): if parent_index.isValid(): # rows inserted were not top level return number_of_added_rows = last - first + 1 if number_of_added_rows == self.model().rowCount(): # rows were added to empty alignment so restore view to full width self.updateGeometry() def _onRowsRemoved(self, parent_index, first, last): if parent_index.isValid(): # rows removed were not top level return if not self.model().rowCount(): # the final rows were removed so hide this view self.updateGeometry()
[docs] def contextMenuEvent(self, event): index = self.indexAt(event.pos()) data = index.data(CustomRole.MetricsType) header_row_clicked = index.row() == 0 and not index.parent().isValid() if header_row_clicked: self.context_menu.setSortType(data) self.context_menu.popup(self.mapToGlobal(event.pos())) super().contextMenuEvent(event)
[docs] def getSizeHint(self): if self.model().rowCount() == 0: # If there are no rows, no need to show alignment metrics return 0 return sum( self.columnWidth(i) for i in range(self.model().columnCount()))
[docs]class AlignmentInfoView(BaseFixedColumnsView): """ View for the fixed columns to the left of the alignment that includes the structure title. This view allows the user to drag-and-drop selected rows to rearrange the order of sequences in the alignment. However, the standard QTreeView drag-and-drop implementation isn't configurable enough for our needs, so this class reimplements drag-and-drop. See the note in the `_canDrop` docstring for additional information. :cvar ADJUST_TIMER_SIGNALS: list of model signals to be connected to start the column width adjustment timer. :vartype ADJUST_TIMER_SIGNALS: list(str) :cvar DRAG_IMG_SCALE: The scale for the drag-and-drop image. A value of 1 means that the dragged rows will be drawn the same size that they appear in the table. :vartype DRAG_IMG_SCALE: float :cvar DRAG_IMG_OPACITY: The opacity for the drag-and-drop image. :vartype DRAG_IMG_OPACITY: float :ivar setAsReferenceSeq: Signal emitted to indicate that the selected sequence should be set as the reference sequence :ivar renameSeqClicked: Signal emitted to indicate that the selected sequence should be renamed :ivar findHomologsClicked: Signal emitted to indicate that the selected sequence should be used for a BLAST query :ivar findInListRequested: Signal to request find sequence in list :ivar mvSeqsClicked: Signal emitted to indicate that the selected sequences should be moved. Emits a list of the selected `sequence.Sequence` objects and the `viewconstants.Direction`. We use `object` as the param type because of a known issue with `enum_speedup`. :ivar rmSeqsClicked: Signal emitted to indicate that the selected sequence(s) should be deleted :ivar clearAnnotationRequested: Signal emitted to turn off the selected annotations :ivar deleteFromAllRequested: Signal emitted to delete the selected non-toggleable annotations (e.g. pfam, predictions) :ivar clearConstraintsRequested: Signal emitted to turn off pairwise constraints :ivar selectRowResidues: Signal emitted to indicate that all residues of all selected sequences should be selected in the alignment. :ivar sortRequested: Signal emitted to sort the sequences in ascending or descending order by a given metric. Emits an object as the metric to sort by 'viewconstants.SortTypes' Emits a boolean for whether to sort in reverse or not 'bool' :ivar getPdbClicked: Signal emitted to indicate that the GetPDB dialog should open :ivar deselectResiduesClicked: Signal emitted to indicate that the selection should be cleared """ ADJUST_TIMER_SIGNALS = [ "modelReset", "rowsInserted", "rowsRemoved", "layoutChanged", "textSizeChanged", "rowHeightChanged" ] SELECTION_ROLE = CustomRole.SeqSelected DRAG_IMG_SCALE = 0.75 DRAG_IMG_OPACITY = 0.5 clearAnnotationRequested = QtCore.pyqtSignal() clearConstraintsRequested = QtCore.pyqtSignal() deleteFromAllRequested = QtCore.pyqtSignal() deselectResiduesClicked = QtCore.pyqtSignal() duplicateAsRefSeqRequested = QtCore.pyqtSignal() duplicateAtBottomRequested = QtCore.pyqtSignal() duplicateAtTopRequested = QtCore.pyqtSignal() duplicateInPlaceRequested = QtCore.pyqtSignal() duplicateIntoNewTabRequested = QtCore.pyqtSignal() findHomologsClicked = QtCore.pyqtSignal() findInListRequested = QtCore.pyqtSignal() mvSeqsClicked = QtCore.pyqtSignal(object) renameSeqClicked = QtCore.pyqtSignal() renameAlnSetClicked = QtCore.pyqtSignal(str) selectAlnSetClicked = QtCore.pyqtSignal(str) deselectAlnSetClicked = QtCore.pyqtSignal(str) dissolveAlnSetClicked = QtCore.pyqtSignal(str) gatherAlnSetsClicked = QtCore.pyqtSignal() rmSeqsClicked = QtCore.pyqtSignal() exportSequencesRequested = QtCore.pyqtSignal() hideSeqsRequested = QtCore.pyqtSignal() selectRowResidues = QtCore.pyqtSignal() setAsReferenceSeq = QtCore.pyqtSignal() sortRequested = QtCore.pyqtSignal(object, bool) getPdbStClicked = QtCore.pyqtSignal() unlinkFromEntryRequested = QtCore.pyqtSignal() linkToEntryRequested = QtCore.pyqtSignal() alnSetCreateRequested = QtCore.pyqtSignal() alnSetAddRequested = QtCore.pyqtSignal(str) alnSetRemoveRequested = QtCore.pyqtSignal() translateDnaRnaRequested = QtCore.pyqtSignal()
[docs] def __init__(self, alignment_view, parent=None): # See parent class for argument documentation. super(AlignmentInfoView, self).__init__(alignment_view, parent=parent) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setSelectionBehavior(self.SelectRows) self.setSelectionMode(self.ExtendedSelection) self.setDragDropMode(self.InternalMove) self.setDefaultDropAction(Qt.MoveAction) self.setMouseTracking(True) self.entered.connect(self._setMouseOverIndex) self._drag_allowed = False self._drag_started = False self._mouse_press_pos = None self._auto_scroll_timer = QtCore.QTimer(self) self._auto_scroll_timer.timeout.connect(self._doAutoScroll) self._auto_scroll_count = 0 self._adjust_title_column_timer = self._initAdjustTitleColumnTimer() self._initContextMenus()
def _initAdjustTitleColumnTimer(self): # hook for patching timer = QtCore.QTimer(self) timer.setSingleShot(True) timer.setInterval(0) timer.timeout.connect(self._adjustTitleColumn) return timer
[docs] @QtCore.pyqtSlot(QtCore.QAbstractItemModel) def setModel(self, model): # See Qt documentation for method documentation old_model = self.model() if old_model is not None: for signal_name in self.ADJUST_TIMER_SIGNALS: getattr(old_model, signal_name).disconnect( self._adjust_title_column_timer.start) super(AlignmentInfoView, self).setModel(model) if model is not None: # The expand columns display an icon with a fixed width. self.setColumnWidth(model.Column.Expand, 20) self.setColumnWidth(model.Column.Struct, 20) char_width = self.fontMetrics().width('W') self.setColumnWidth(model.Column.Chain, char_width * 3) # Stretch the title column to fill width of the table, so that # when user moves the slider, this column adjusts its width. self.header().setSectionResizeMode(model.Column.Title, QtWidgets.QHeaderView.Stretch) selection_model = SequenceSelectionProxyModel(model, self) self.setSelectionModel(selection_model) # We use _update_cell_size_timer to avoid unnecessary repetition of # _adjustTitleColumn. for signal_name in self.ADJUST_TIMER_SIGNALS: getattr(model, signal_name).connect( self._adjust_title_column_timer.start) # Even though we can click on the structure column to select, # we don't actually want to paint it as selected even when it is self._selectable_cols.remove(model.Column.Struct)
[docs] def setMenuModel(self, model): self.seq_context_menu.setModel(model)
def _initContextMenus(self): """ Set up context menus for the view """ title_menu = SeqTitleContextMenu(parent=self) title_menu.sortRequested.connect(self.sortRequested) title_menu.findInListRequested.connect(self.findInListRequested) self._title_context_menu = title_menu chain_menu = SeqChainContextMenu(parent=self) chain_menu.sortRequested.connect(self.sortRequested) self._chain_context_menu = chain_menu seq_menu = AlignmentInfoContextMenu(parent=self) # yapf: disable seq_menu.deleteRequested.connect(self.rmSeqsClicked) seq_menu.exportSeqsRequested.connect(self.exportSequencesRequested) seq_menu.hideRequested.connect(self.hideSeqsRequested) seq_menu.deselectAllResiduesRequested.connect(self.deselectResiduesClicked) seq_menu.duplicateAsRefSeqRequested.connect(self.duplicateAsRefSeqRequested) seq_menu.duplicateAtBottomRequested.connect(self.duplicateAtBottomRequested) seq_menu.duplicateAtTopRequested.connect(self.duplicateAtTopRequested) seq_menu.duplicateInPlaceRequested.connect(self.duplicateInPlaceRequested) seq_menu.duplicateIntoNewTabRequested.connect(self.duplicateIntoNewTabRequested) seq_menu.moveSeqRequested.connect(self.mvSeqsClicked) seq_menu.getStructurePdbRequested.connect(self.getPdbStClicked) seq_menu.unlinkFromEntryRequested.connect(self.unlinkFromEntryRequested) seq_menu.linkToEntryRequested.connect(self.linkToEntryRequested) seq_menu.renameRequested.connect(self.renameSeqClicked) seq_menu.selectAllResiduesRequested.connect(self.selectRowResidues) seq_menu.setAsReferenceSeqRequested.connect(self.setAsReferenceSeq) seq_menu.sortRequested.connect(self.sortRequested) seq_menu.alnSetCreateRequested.connect(self.alnSetCreateRequested) seq_menu.alnSetAddRequested.connect(self.alnSetAddRequested) seq_menu.alnSetRemoveRequested.connect(self.alnSetRemoveRequested) seq_menu.translateDnaRnaRequested.connect(self.translateDnaRnaRequested) # yapf: enable self.seq_context_menu = seq_menu aln_set_menu = AlignmentSetContextMenu(parent=self) aln_set_menu.renameRequested.connect(self.renameAlnSetClicked) aln_set_menu.selectRequested.connect(self.selectAlnSetClicked) aln_set_menu.deselectRequested.connect(self.deselectAlnSetClicked) aln_set_menu.dissolveRequested.connect(self.dissolveAlnSetClicked) aln_set_menu.gatherRequested.connect(self.gatherAlnSetsClicked) self._aln_set_context_menu = aln_set_menu anno_menu = AnnotationContextMenu(parent=self) anno_menu.clearRequested.connect(self.clearAnnotationRequested) anno_menu.clearConstraintsRequested.connect( self.clearConstraintsRequested) anno_menu.deleteFromAllRequested.connect(self.deleteFromAllRequested) anno_menu.selectAssociatedResiduesRequested.connect( self.selectBindingSiteResidues) self._annotation_context_menu = anno_menu structure_menu = StructureColumnContextMenu(parent=self) self._structure_context_menu = structure_menu # Any menu shown over a row with a hover effect (all rows except title, # ruler, and spacer) should be in this tuple hover_effect_menus = (self.seq_context_menu, self._aln_set_context_menu, self._annotation_context_menu, self._structure_context_menu) for menu in hover_effect_menus: menu.aboutToHide.connect(self._clearContextMenuIndex) def _getSeqAndLig(self): """ Get the sequence and the ligand name on which the right menu is showed up. :return: Sequence and the ligand name :rtype: tuple(protein.sequence.ProteinSequence, str) """ menu_pos = self.mapFromGlobal(self._annotation_context_menu.pos()) index = self.indexAt(menu_pos) ligand = index.data(Qt.DisplayRole) current_sequence = index.data(CustomRole.Seq) return current_sequence, ligand
[docs] def selectBindingSiteResidues(self): """ Select all residues that are in contact with the ligand. """ aln = self.model().getAlignment() current_sequence, ligand = self._getSeqAndLig() seq_eid = current_sequence.entry_id if not ligand or not seq_eid: return aln.selectBindingSitesForLigand(ligand, seq_eid)
def _adjustTitleColumn(self): """ Slot that resizes the title column in response to model changes """ model = self.model() width = model.getTitleColumnWidth() self.setColumnWidth(model.Column.Title, width) self.updateGeometry() def _groupByChanged(self, group_by): # See SeqExpansionViewMixin for documentation super(AlignmentInfoView, self)._groupByChanged(group_by) allow_expansion = group_by is viewconstants.GroupBy.Sequence self.setRootIsDecorated(allow_expansion)
[docs] def selectionCommand(self, index, event=None): """ See Qt documentation for method documentation. We modify standard behavior here in two cases: - Prevent selection changes on right mouse button events on already selected rows since they show the context menu. - If the user clicks on an already selected row, we don't know if they want to start a drag-and-drop operation or select-only the clicked row. As such, we return NoUpdate. QAbstractItemModel handles NoUpdate on mouse press as a special case and calls this method again to potentially change selection on mouse release. When that happens, we select-only the row iff drag-and-drop wasn't started. """ if isinstance(event, QtGui.QMouseEvent): if index.internalId() != TOP_LEVEL: return QtCore.QItemSelectionModel.NoUpdate if event.button() == Qt.RightButton: # Qt doesn't create context menus for right-clicks with # modifiers so we do it ourselves here. self._showContextMenu(event.pos()) sel_model = self.model().getAlignment().seq_selection_model if not sel_model.hasSelection(): return QtCore.QItemSelectionModel.ClearAndSelect elif event.modifiers() == Qt.ShiftModifier: return QtCore.QItemSelectionModel.SelectCurrent elif event.modifiers() == Qt.ControlModifier: return QtCore.QItemSelectionModel.Select elif index.data(CustomRole.SeqSelected): return QtCore.QItemSelectionModel.NoUpdate elif self._drag_allowed: # _drag_allowed is set in mousePressEvent when the user # left-clicks on a draggable row if event.type() == event.MouseButtonPress: if index.data(CustomRole.SeqSelected): return QtCore.QItemSelectionModel.NoUpdate else: return QtCore.QItemSelectionModel.ClearAndSelect elif event.type() == event.MouseButtonRelease: if self._drag_started: return QtCore.QItemSelectionModel.NoUpdate else: return QtCore.QItemSelectionModel.ClearAndSelect return super().selectionCommand(index, event)
[docs] def contextMenuEvent(self, event): self._showContextMenu(event.pos())
def _showContextMenu(self, pos): model = self.model() index = self.indexAt(pos) column = index.column() if column == model.Column.Expand: # There are no context menus for the expansion column return if index.row() == 0 and index.internalId() == TOP_LEVEL: if column == model.Column.Title: self._title_context_menu.popup(self.mapToGlobal(pos)) elif column == model.Column.Chain: self._chain_context_menu.popup(self.mapToGlobal(pos)) # There are no context menus for the struct header column return menu = None row_type = index.data(CustomRole.RowType) if row_type is PROT_SEQ_ANN_TYPES.alignment_set: menu = self._getAlnSetContextMenu(index) elif self._clickedAnnotation(index): menu = self._getAnnotationContextMenu(index) elif row_type is RowType.Sequence: clicked_seq = index.data(CustomRole.Seq) if (column == model.Column.Struct and clicked_seq and clicked_seq.hasStructure()): aln = model.getAlignment() sel_seqs = aln.getSelectedSequences() menu = self._getStructureColumnContextMenu( clicked_seq, sel_seqs) else: menu = self._getSeqContextMenu(clicked_seq) if menu is not None: model.setMouseOverIndex(None) model.setContextOverIndex(index) menu.popup(self.mapToGlobal(pos)) @QtCore.pyqtSlot() def _clearContextMenuIndex(self): """ Slot to clear the index the context menu is over. All context menus shown in `_showContextMenu` (except title row menus) must connect `aboutToHide` to this slot. """ self.model().setContextOverIndex(None) def _clickedAnnotation(self, index): row_type = index.data(CustomRole.RowType) return row_type is not None and row_type != PROT_ALN_ANN_TYPES.indices and ( row_type in PROT_SEQ_ANN_TYPES or row_type in PROT_ALN_ANN_TYPES) def _getStructureColumnContextMenu(self, clicked_seq, sel_seqs): if not maestro: return context_menu = self._structure_context_menu context_menu.setSelectedSeqs(sel_seqs) is_included = (clicked_seq.visibility is not viewconstants.Inclusion.Excluded) context_menu.setIncluded(is_included) return context_menu def _getAlnSetContextMenu(self, index): context_menu = self._aln_set_context_menu aln = self.model().getAlignment() seq = index.data(CustomRole.Seq) set_name = aln.alnSetForSeq(seq).name context_menu.set_name = set_name return context_menu def _getAnnotationContextMenu(self, index): context_menu = self._annotation_context_menu context_menu.annotation = index.data(CustomRole.RowType) aln = self.model().getAlignment() sel_anns = aln.ann_selection_model.getSelection() context_menu.setSelectedAnnotations(sel_anns) return context_menu def _getSeqContextMenu(self, clicked_seq): context_menu = self.seq_context_menu if clicked_seq is not None: context_menu.updateUnlinkedFromSequences(clicked_seq.hasStructure()) can_translate = isinstance(clicked_seq, sequence.NucleicAcidSequence) context_menu.updateCanTranslate(can_translate) aln = self.model().getAlignment() seq_set = aln.alnSetForSeq(clicked_seq) context_menu.createAlnSetMenu(in_set=bool(seq_set)) set_name = seq_set.name if seq_set else None context_menu = self._disableCurrentSetName(context_menu, set_name) return context_menu def _disableCurrentSetName(self, context_menu, set_name): """ Disable the set in the "Add to Set" sub-menu, if the clicked sequence belong to the set and return the menu. :param context_menu: Menu from which the QAction needs to be disabled. :type context_menu: AlignmentInfoContextMenu :param set_name: Name of the set to be disabled or None if clicked seq doesn't belong to any set. :type set_name: str or None :return: AlignmentInfoContextMenu """ aln = self.model().getAlignment() selected_seqs = aln.seq_selection_model.getSelection() unique_selected_seq_set = set() for seq in selected_seqs: seq_set = aln.alnSetForSeq(seq) if seq_set is not None: unique_selected_seq_set.add(seq_set.name) aln_has_single_set = len(unique_selected_seq_set) == 1 aln_set_menu = context_menu.aln_set.menu() # 'Add to Set' menu-item is at index 2. aln_set_add_action = aln_set_menu.actions()[2] aln_set_add_menu = aln_set_add_action.menu() for action in aln_set_add_menu.actions(): disable = aln_has_single_set and action.text() == set_name action.setDisabled(disable) return context_menu @QtCore.pyqtSlot(QtCore.QModelIndex) def _setMouseOverIndex(self, index): """ Set the given index as having the mouse over it """ self.model().setMouseOverIndex(index) def _isMouseOverIndex(self, index): """ Check whether the mouse is over the specified row :param index: An index representing the row to check :type index: QtCore.QModelIndex :return: A tuple of: - Whether the specified row has the mouse over it. Note that, for the purposes of this check, mousing over the expansion column does *not* count as mousing over the row. - Whether the mouse is over the structure column of *any* row. :rtype: tuple(bool, bool) """ model = self.model() mouse_over_row = model.isMouseOverIndex(index) mouse_over_struct_col = model.isMouseOverStructCol() return mouse_over_row, mouse_over_struct_col
[docs] def mousePressEvent(self, event): """ See Qt documentation for method documentation. We modify standard behavior here in two cases: - On right-clicks, we show the appropriate context menu. - On left-clicks, we check to see if drag-and-drop is possible and, if so, update self._drag_allowed and self._mouse_press_pos. """ index = self.indexAt(event.pos()) ann_handled = self._handleMousePressOnAnn(index, event) if ann_handled: return if (event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier and index.flags() & Qt.ItemIsDragEnabled): self._drag_allowed = True self._mouse_press_pos = event.pos() super().mousePressEvent(event)
[docs] def mouseMoveEvent(self, event): """ See Qt documentation for method documentation. Since our selection model doesn't store the selection (see `AbstractSelectionProxyModel`), `QAbstractItemModel::mouseMoveEvent` always thinks that there's no selection and will never start drag-and-drop. To get around this, we manually set the state to `DraggingState` when appropriate. (Note that DraggingState doesn't actually start a drag-and-drop operation. Instead, drag-and-drop starts when:: - state is already set to `DraggingState` - the mouse has moved at least `QApplication::startDragDistance()` pixels """ if (event.buttons() and self._drag_allowed and self.state() == self.NoState): self.setState(self.DraggingState) super().mouseMoveEvent(event)
[docs] def mouseReleaseEvent(self, event): # See Qt documentation for method documentation super().mouseReleaseEvent(event) self._resetDragAndDropState()
[docs] def mouseDoubleClickEvent(self, event): # See Qt documentation for method documentation super().mouseDoubleClickEvent(event) index = self.indexAt(event.pos()) if (index.data(CustomRole.RowType) is RowType.Sequence and index.column() == self.model().Column.Title): self.selectionModel().select( index, QtCore.QItemSelectionModel.ClearAndSelect) self.renameSeqClicked.emit()
[docs] def leaveEvent(self, event): # See Qt documentation for method documentation self.model().setMouseOverIndex(None) super().leaveEvent(event)
def _handleMousePressOnAnn(self, index, event): """ :return: Whether the event was handled and the caller should return early """ ann_sel_model = self.model().getAlignment().ann_selection_model row_type = index.data(CustomRole.RowType) if not index.flags() & Qt.ItemIsSelectable: # For non-selectable rows, clear selection but don't handle event ann_sel_model.clearSelection() return False if row_type not in viewmodel.SELECTABLE_ANNO_TYPES: # For non-annotation rows, don't handle event return False selection_state = None if ann_sel_model.hasSelection(): ctrl_pressed = event.modifiers() == Qt.ControlModifier if index.data(CustomRole.AnnotationSelected): if event.button() == Qt.RightButton: # Prevent selection event when right-clicking on # already-selected row return True elif ctrl_pressed: # Deselect selection_state = False elif ctrl_pressed or event.modifiers() == Qt.ShiftModifier: # TODO MSV-3289 implement range selection for shift # Select-also selection_state = True if selection_state is None: # If behavior was not customized by a modifier, clear and select ann_sel_model.clearSelection() selection_state = True self.model().setAnnSelectionState(index, selection_state) return True def _resetDragAndDropState(self): """ Reset any internal variables used to keep track of drag-and-drop state. """ self._drag_allowed = False self._drag_started = False self._mouse_press_pos = None
[docs] def startDrag(self, supported_actions): # See Qt documentation for method documentation self._drag_started = True model = self.model() drag = QtGui.QDrag(self) # the model already knows what the selection is, so we don't have to # pass a list of indices to mimeData mime_data = model.mimeData([]) try: pixmap, rect = self._getDragImageAndRect() except NoDragRowsError: # This should never happen, but it sometimes does when the user # clicks and drags from the reference sequence. (That row isn't # flagged as draggable, so the view should never enter # DraggingState.) See also MAE-37388, which is the equivalent # problem occurring in the HPT. return hotspot = self.DRAG_IMG_SCALE * \ (self._mouse_press_pos - rect.topLeft()) drag.setMimeData(mime_data) drag.setPixmap(pixmap) drag.setHotSpot(hotspot) default_drop_option = self.defaultDropAction() drag.exec(supported_actions, default_drop_option) # mouseReleaseEvent may or may not get called after the drop. (See # https://bugreports.qt.io/browse/QTBUG-40733.) Since drag.exec blocks # until the drop happens, we reset the drag-and-drop variables here in # case mouseReleaseEvent isn't called. self._resetDragAndDropState()
def _getDragImageAndRect(self): """ Create an image for the rows that are about to be dragged. :return: A tuple of - The image of all selected rows that are currently visible. - A rectangle of the area represented by the image :rtype: tuple(QtGui.QPixmap, QtCore.QRect) """ selected = self._getDragRows() if not selected: # This means that the user has started drag-and-drop without any # selected rows visible, which shouldn't happen raise NoDragRowsError model = self.model() first_col = model.Column.Struct last_col = model.Column.Chain rect = self._getDragRect(selected, first_col, last_col) pixmap = self._getDragImage(selected, rect, first_col, last_col) return pixmap, rect def _getDragRows(self): """ Figure out which rows we should draw for the drag-and-drop image. We draw any currently visible rows that are selected other than the reference sequence. We include annotation rows when in group-by-sequence mode (since annotations are adjacent to the selected sequence), but not when in group-by-type mode. :return: A list of indices, each representing a row to draw. :rtype: list[QtCore.QModelIndex] """ rect = self.viewport().rect() bottom_index = self.indexAt(rect.bottomLeft()) cur_index = self.indexAt(QtCore.QPoint(1, 1)) selected = [] while cur_index.isValid(): if cur_index.data(CustomRole.IncludeInDragImage): selected.append(cur_index) if cur_index == bottom_index: break cur_index = self.indexBelow(cur_index) return selected def _getDragRect(self, selected, first_col, last_col): """ Create a QRect for the drag-and-drop image. :param selected: A list of indices for each row to draw :type selected: list[QtCore.QModelIndex] :param first_col: The left-most column to be drawn :type first_col: int :param last_col: The right-most column to be drawn :type last_col: int :return: The requested QRect :rtype: QtCore.QRect """ top_index = selected[0] top_index = top_index.sibling(top_index.row(), first_col) topleft = self.visualRect(top_index).topLeft() bottom_index = selected[-1] bottom_index = bottom_index.sibling(bottom_index.row(), last_col) bottomright = self.visualRect(bottom_index).bottomRight() return QtCore.QRect(topleft, bottomright) def _getDragImage(self, selected, img_rect, first_col, last_col): """ Draw the drag-and-drop image. :param selected: A list of indices for each row to draw :type selected: list[QtCore.QModelIndex] :param img_rect: :type img_rect: QtCore.QRect :param first_col: The left-most column to draw :type first_col: int :param last_col: The right-most column to draw :type last_col: int :return: The drag-and-drop image :rtype: QtGui.QPixmap """ pixmap = QtGui.QPixmap(img_rect.size()) pixmap.fill(Qt.transparent) painter = QtGui.QPainter(pixmap) painter.setOpacity(self.DRAG_IMG_OPACITY) painter.scale(self.DRAG_IMG_SCALE, self.DRAG_IMG_SCALE) option = qt_utils.get_view_item_options(self) row_rect = QtCore.QRect(img_rect) row_rect.moveLeft(img_rect.left()) img_rect_top = img_rect.top() for cur_index in selected: with self._paintingColumns(first_col, last_col): index_rect = self.visualRect(cur_index) row_rect.setTop(index_rect.top() - img_rect_top) row_rect.setBottom(index_rect.bottom() - img_rect_top) option.rect = row_rect self.drawRow(painter, option, cur_index) return pixmap
[docs] def dragEnterEvent(self, event): # See Qt documentation for method documentation # This implementation is based on the QAbstractItemView::dragEnterEvent # implementation if event.source() is self: self.setState(self.DraggingState) event.accept() else: event.ignore()
[docs] def dragMoveEvent(self, event): # See Qt documentation for method documentation # This implementation is based on the QAbstractItemView::dragMoveEvent # implementation if event.source() is not self: event.ignore() pos = event.pos() _, drop_y = self._canDrop(pos) if drop_y != self._drop_indicator_y: self.viewport().update() self._drop_indicator_y = drop_y if drop_y is None: event.ignore() else: event.acceptProposedAction() self._startAutoScrollIfNeeded(pos)
[docs] def dragLeaveEvent(self, event): # See Qt documentation for method documentation # This implementation is based on the QAbstractItemView::dragLeaveEvent # implementation self._stopAutoScroll() self.setState(self.NoState) if self._drop_indicator_y is not None: self._drop_indicator_y = None self.viewport().update()
[docs] def dropEvent(self, event): # See Qt documentation for method documentation # This implementation is based on the QAbstractItemView::dropEvent # implementation self._stopAutoScroll() self.setState(self.NoState) if self._drop_indicator_y is not None: self._drop_indicator_y = None self.viewport().update() index, _ = self._canDrop(event.pos()) if index is not None: event.acceptProposedAction() self.model().moveSelectionBelow(index) self._syncExpansion()
def _canDrop(self, pos): """ Determine if we can drop drag-and-dropped rows at the specified point. :note: The standard Qt model/view method for determining where rows can be dropped is to check flags(index) & Qt.ItemIsDragEnabled. However, due to the way this is implemented, if we can drop between two rows, then 1) we must be able to drop between any two rows in that same group 2) we must be able to drop on the parent index of that group Those restrictions are unworkable here, since we need to be able to drop in between sequence rows but not on sequence rows and not in between global annotation (which are also top level rows). As a result, this method relies on two custom roles: CanDropAbove and CanDropBelow. :param pos: The location to check :type pos: QtCore.QPoint :return: A tuple of:: - If we can drop, the index that the drop would occur below. None otherwise. - If we can drop, the y-coordinate to draw the drop indicator at. None otherwise. :rtype: tuple(QtCore.QModelIndex or None, int or None) """ index = self.indexAt(pos) if not index.isValid(): # We're below the bottom of the last row, so see if we can drop # below the last row model = self.model() last_index = model.index(model.rowCount() - 1, 0) child_row_count = model.rowCount(last_index) if child_row_count and self.isExpanded(last_index): last_index = model.index(child_row_count - 1, 0, last_index) if last_index.data(CustomRole.CanDropBelow): rect = self.visualRect(last_index) return last_index, rect.bottom() + 1 return None, None rect = self.visualRect(index) if pos.y() < rect.center().y(): # The cursor is over the top half of the row prev_index = self.indexAbove(index) if (index.data(CustomRole.CanDropAbove) or self._canDropBelow(prev_index)): # We can drop above this row return prev_index, rect.top() elif index.data(CustomRole.RowType) is RowType.Spacer: # If this row is a spacer and we can't drop above it, see if we # can drop below it. next_index = self.indexBelow(index) if (self._canDropBelow(index) or next_index.data(CustomRole.CanDropAbove)): return index, rect.bottom() + 1 elif prev_index.data(CustomRole.RowType) is RowType.Spacer: # If the row above this one is a spacer, see if we can drop # above that row. prev_prev_index = self.indexAbove(prev_index) if (prev_index.data(CustomRole.CanDropAbove) or self._canDropBelow(prev_prev_index)): prev_rect = self.visualRect(prev_index) return prev_prev_index, prev_rect.top() else: # The cursor is over the bottom half of the row if self._canDropBelow(index): return index, rect.bottom() + 1 next_index = self.indexBelow(index) if next_index.data(CustomRole.CanDropAbove): return index, rect.bottom() + 1 elif index.data(CustomRole.RowType) is RowType.Spacer: # If this row is a spacer and we can't drop below it, see if we # can drop above it. prev_index = self.indexAbove(index) if (index.data(CustomRole.CanDropAbove) or self._canDropBelow(prev_index)): return prev_index, rect.top() elif next_index.data(CustomRole.RowType) is RowType.Spacer: # If the row below this one is a spacer, see if we can drop # below that row. next_next_index = self.indexBelow(next_index) if (self._canDropBelow(next_index) or next_next_index.data(CustomRole.CanDropAbove)): next_rect = self.visualRect(next_index) return next_index, next_rect.bottom() + 1 # We can't drop anywhere nearby return None, None def _canDropBelow(self, index): """ See if we can drop drag-and-dropped rows below the specified row. Note that we can never drop below an expanded group, since that would mean that we're dropping in between a sequence and its annotations. :param index: An index in the row to check. :type index: QtCore.QModelIndex :return: Whether below the row is a valid drop target. :rtype: bool """ model = self.model() # isExpanded only works for column 0 indices: index = index.sibling(index.row(), 0) if model.hasChildren(index) and self.isExpanded(index): return False return model.data(index, CustomRole.CanDropBelow) def _startAutoScrollIfNeeded(self, pos): """ Start auto-scroll (i.e. scrolling while in drag-and-drop mode when the mouse cursor is close to the edges of the view) if the specified position is close enough to the view margins. :param pos: The cursor location. :type pos: QtCore.QPoint """ y = pos.y() margin = self.autoScrollMargin() if y < margin or y > self.height() - margin: # The 150 value is taken from # QAbstractItemViewPrivate::startAutoScroll self._auto_scroll_timer.start(150) self._auto_scroll_count = 0 def _stopAutoScroll(self): self._auto_scroll_timer.stop() self._auto_scroll_count = 0 def _doAutoScroll(self): """ If the cursor is currently close to the view margins, auto-scroll the view. Activated during drag-and-drop. Note that this implementation is based on QAbstractItemView::doAutoScroll. """ scroll = self.verticalScrollBar() # auto-scroll gradually accelerates up to a max of a page at a time if self._auto_scroll_count < scroll.pageStep(): self._auto_scroll_count += 1 cursor_pos = QtGui.QCursor.pos() pos = self.viewport().mapFromGlobal(cursor_pos) y = pos.y() margin = self.autoScrollMargin() if y < margin: scroll.setValue(scroll.value() - self._auto_scroll_count) elif y > self.height() - margin: scroll.setValue(scroll.value() + self._auto_scroll_count) else: self._stopAutoScroll() return # don't show the drop indicator while scrolling if self._drop_indicator_y is not None: self._drop_indicator_y = None self.viewport().update()
[docs] def getSizeHint(self): # Enough to show title and chain header. User can then # use the splitter to expose more of the title as desired. return self.model().getMinimumWidth()
[docs] def resizeEvent(self, event): """ Clear the text cache when the view is resized (will re-calculate eliding). """ self._row_del.clearCache() super().resizeEvent(event)
[docs]class NoDragRowsError(Exception): """ An exception raised when the user tries to drag-and-drop but there are no selected rows. This should never happen, but it does. See `AlignmentInfoView.startDrag`. """
# This class intentionally left blank
[docs]class EditorDelegate(QtWidgets.QStyledItemDelegate): """ A delegate for editing residues. This delegate is *not* involved in painting, as all painting is handled by the `row_delegates` module. :cvar GAP: The gap character to pass to the model. :vartype GAP: str :cvar INSERTION_EDITOR_WIDTH: How wide (in number of cells) we should make the insertion editor. :vartype INSERTION_EDITOR_WIDTH: int or float :cvar KEY_TO_ADJACENT: A mapping of Qt key constants to `Adjacent` constants. :vartype KEY_TO_ADJACENT: dict(Qt.Key, Adjacent) :cvar moveChangeEditorRequested: A signal emitted when the user has requested that the change residue editor be moved to a new cell. Emitted with an `Adjacent` value corresponding to the direction to move the editor. :vartype moveChangeEditorRequested: QtCore.pyqtSignal """ GAP = protein_constants.GAP_CHARS[0] INSERTION_EDITOR_WIDTH = 5 KEY_TO_ADJACENT = { Qt.Key_Up: Adjacent.Up, Qt.Key_Down: Adjacent.Down, Qt.Key_Left: Adjacent.Left, Qt.Key_Right: Adjacent.Right } moveChangeEditorRequested = QtCore.pyqtSignal(Adjacent)
[docs] def __init__(self, parent): """ :param parent: Parent view. Must be `AbstractAlignmentView` because setModelData calls parent methods. :type parent: AbstractAlignmentView """ super().__init__(parent) self._editor_type = _EditorType.Change self._num_replacement_res = None
[docs] def setEditorType(self, mode, num_replacement_res=None): """ Specify what type of editor should be created the next time we edit a cell. :param mode: What type of editor to create. :type mode: _EditorType :param num_replacement_res: If `mode` is `_EditorType.Replacement`, how many cells wide the editor should be. Otherwise, should be `None`. :type num_replacement_res: int or None """ self._editor_type = mode self._num_replacement_res = num_replacement_res
[docs] def createEditor(self, parent, option, index): # See Qt documentation for method documentation editor = QtWidgets.QLineEdit(parent) if self._editor_type is _EditorType.Change: editor.setStyleSheet("border: 1px solid blue") editor.setMaxLength(1) editor.setPlaceholderText(viewconstants.DEFAULT_GAP) # create a copy of the font so we don't modify the model's font font = QtGui.QFont(index.data(Qt.FontRole)) # make the font slightly smaller so it fits in the editor better font.setPointSize(font.pointSize() - 2) editor.setFont(font) # raise the text a pixel higher to make up for the fact that we shrank # it editor.setTextMargins(0, 0, 0, 1) editor.textEdited.connect(self._convertSpacesToGaps) return editor
[docs] def updateEditorGeometry(self, editor, option, index): # See Qt documentation for method documentation if self._editor_type is _EditorType.Change: rect = QtCore.QRect(option.rect) elif self._editor_type is _EditorType.Replace: rect = QtCore.QRect(option.rect) rect.setWidth(rect.width() * self._num_replacement_res) elif self._editor_type is _EditorType.Insert: width = option.rect.width() * self.INSERTION_EDITOR_WIDTH height = option.rect.height() x = option.rect.left() - width // 2 if x < 0: # if the editor would extend off the left edge of the view, move # it to the right x = 0 elif x + width > editor.parent().width(): # if the editor would extend off the right edge of the view, # move it to the left x = editor.parent().width() - width y = option.rect.top() - height if y < 0: # if the editor starts above the top of the view, move it below # the row being edited y += 2 * height rect = QtCore.QRect(x, y, width, height) # make the editor a little larger than the cell so it's more visible rect += QtCore.QMargins(2, 2, 2, 2) editor.setGeometry(rect)
def _convertSpacesToGaps(self, text): """ Convert all spaces in the editor to mid-dots as the user types. May only be called as a slot. :param text: The new contents of the editor. :type text: str """ if " " in text: editor = self.sender() cursor_position = editor.cursorPosition() new_text = text.replace(" ", viewconstants.DEFAULT_GAP) editor.setText(new_text) editor.setCursorPosition(cursor_position)
[docs] def setEditorData(self, editor, index): # See Qt documentation for method documentation if self._editor_type is _EditorType.Change: res = index.data(Qt.EditRole) editor.setText(res) elif self._editor_type is _EditorType.Replace: text = index.model().getSingleLetterCodeForSelectedResidues() text = text.replace(" ", viewconstants.DEFAULT_GAP) editor.setText(text)
# don't load any data for insertion editors
[docs] def setModelData(self, editor, model, index): # See Qt documentation for method documentation text = editor.text() text = text.replace(viewconstants.DEFAULT_GAP, self.GAP) if self._editor_type is _EditorType.Change: if not text: text = self.GAP replacement = viewmodel.SeqSliceReplacement(text) edit_func = partial(model.setData, index, replacement, CustomRole.ReplacementEdit) elif self._editor_type is _EditorType.Replace: replacement = viewmodel.SeqSliceReplacement( text, self._num_replacement_res) edit_func = partial(model.setData, index, replacement, CustomRole.ReplacementEdit) elif self._editor_type is _EditorType.Insert: edit_func = partial(model.setData, index, text, CustomRole.InsertionEdit) else: raise RuntimeError(f"_editor_type {self._editor_type} is invalid") self.parent().editAndMaybeClearAnchors(edit_func)
[docs] def eventFilter(self, editor, event): """ Handle up, down, left, and right keypresses in the editor. See Qt documentation for additional method documentation. Note that QAbstractItemView automatically installs the delegate as an event filter for the editor, which will cause this function to be called when required. """ if (event.type() == event.KeyPress and event.modifiers() == Qt.NoModifier): key = event.key() if (self._editor_type is _EditorType.Change and key in self.KEY_TO_ADJACENT): direction = self.KEY_TO_ADJACENT[key] self.moveChangeEditorRequested.emit(direction) return True elif key in (Qt.Key_Up, Qt.Key_Down): # Prevent the current index from moving up or down a row while a # replacement or insertion editor is open. Left and right key # presses will be accepted by the line edit (to move the # cursor), so we don't need to filter those out. return True return super().eventFilter(editor, event)