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

import bisect
import collections
import copy
import itertools
import math
import types
import typing
from functools import partial

import inflect

from schrodinger.application.msv.gui import color
from schrodinger.application.msv.gui import gui_models
from schrodinger.application.msv.gui import msv_rc
from schrodinger.application.msv.gui import picking
from schrodinger.application.msv.gui.gui_alignment import \
    GlobalAnnotationRowInfo
from schrodinger.application.msv.gui.gui_alignment import \
    SequenceAnnotationRowInfo
from schrodinger.application.msv.gui.viewconstants import DEFAULT_GAP
from schrodinger.application.msv.gui.viewconstants import TERMINAL_GAP
from schrodinger.application.msv.gui.viewconstants import TOP_LEVEL
from schrodinger.application.msv.gui.viewconstants import Adjacent
from schrodinger.application.msv.gui.viewconstants import AnnotationType
from schrodinger.application.msv.gui.viewconstants import ColorByAln
from schrodinger.application.msv.gui.viewconstants import ColumnMode
from schrodinger.application.msv.gui.viewconstants import CustomRole
from schrodinger.application.msv.gui.viewconstants import GroupBy
from schrodinger.application.msv.gui.homology_modeling.constants import HomologyStatus
from schrodinger.application.msv.gui.viewconstants import IdentityDisplayMode
from schrodinger.application.msv.gui.picking import PickMode
from schrodinger.application.msv.gui.viewconstants import ResidueFormat
from schrodinger.application.msv.gui.viewconstants import ResSelectionBlockStart
from schrodinger.application.msv.gui.viewconstants import RoleBase
from schrodinger.application.msv.gui.viewconstants import RowType
from schrodinger.application.msv.gui.viewconstants import SeqInfo
from schrodinger.application.msv.gui.viewconstants import included_map
from schrodinger.protein import annotation
from schrodinger.protein import nonstandard_residues
from schrodinger.protein import residue
from schrodinger.protein import sequence
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui import sketcher
from schrodinger.ui.qt import structure2d
from schrodinger.ui.qt import table_helper
from schrodinger.ui.qt import table_speed_up
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt.standard.colors import get_contrasting_color
from schrodinger.utils.scollections import IdDict

msv_rc = msv_rc  # noqa

MAX_ANNOTATIONS = 50
MAX_DOMAINS = 50
MAX_LIGANDS = 500
MAX_SEQ_PROPS = 500

# Constants to help make the proxy row calculations easier to read:
# When determining the number of rows in a grouping, we need to account for the
# blank spacer row after the group.
SPACER = 1
# When calculating the number of annotation types, we need to account for the
# sequence itself
SEQUENCE = 1
# Adding or subtracting one to account for the fact that rows and columns are
# zero-indexed
ZERO_INDEXED = 1

ALN_ANNO_TYPES = annotation.ProteinAlignmentAnnotations.ANNOTATION_TYPES
SEQ_ANNO_TYPES = annotation.ProteinSequenceAnnotations.ANNOTATION_TYPES
PRED_ANNO_TYPES = annotation.ProteinSequenceAnnotations.PRED_ANNOTATION_TYPES
RES_PROP_ANNO_TYPES = annotation.ProteinSequenceAnnotations.RES_PROPENSITY_ANNOTATIONS
NON_SELECTABLE_ANNO_TYPES = frozenset({
    ALN_ANNO_TYPES.indices,
    SEQ_ANNO_TYPES.pairwise_constraints,
    SEQ_ANNO_TYPES.alignment_set,
    SEQ_ANNO_TYPES.proximity_constraints,
})
SELECTABLE_ANNO_TYPES = frozenset(ALN_ANNO_TYPES).union(
    SEQ_ANNO_TYPES) - NON_SELECTABLE_ANNO_TYPES

# Maximum number of residues to fit in one column of the sequence logo
MAX_AA_IN_LOGO = 5

# Only display residue numbers divisible by this number
RESNUM_INCR = 5

# Whether custom MSV fonts have been loaded to the QFontDatabase
_font_added = False


[docs]class SeqSliceReplacement(typing.NamedTuple): """ Data about replacing a portion of a sequence. Used to pass information from view.EditorDelegate.setModelData to SequenceAlignmentModel._setData. :ivar new_residues: The residues to replace the sequence with. :ivar num_to_replace: The number of residues to replace. Note that this is not necessarily equal to `len(new_residues)` since the replacement doesn't have to be the same length as what its replacing. """ new_residues: str num_to_replace: int = 1
[docs]class CacheNamespace(types.SimpleNamespace): """ A SimpleNamespace for storing all the caches in SequenceAlignmentModel. This is so they can be easily cleared all at once. """
[docs] def __init__(self, **kwargs): self._cache_names = kwargs.keys() super().__init__(**kwargs)
[docs] def clear(self): for cache in self._cache_names: setattr(self, cache, None)
[docs]class SlotsInPythonMixin: """ If a model connects a signal to a non-slot C++ method, then PyQt won't be able to destroy the model until the main event loop runs. (`ProcessEvents` calls aren't sufficient since they don't process DeferredDelete events.) This will prevent models from being cleaned up during unit tests, which will prevent the panels and alignments from being cleaned up as well. This can lead to massive memory usage, especially for the performance tests and the Hypothesis stateful tests. To avoid this, we override all of the C++ methods commonly used as slots with Python methods. """
[docs] @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def beginInsertColumns(self, parent, first, last): super().beginInsertColumns(parent, first, last)
[docs] @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def beginInsertRows(self, parent, first, last): super().beginInsertRows(parent, first, last)
[docs] @QtCore.pyqtSlot() def endInsertColumns(self): super().endInsertColumns()
[docs] @QtCore.pyqtSlot() def endInsertRows(self): super().endInsertRows()
[docs] @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def beginRemoveColumns(self, parent, first, last): super().beginRemoveColumns(parent, first, last)
[docs] @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def beginRemoveRows(self, parent, first, last): super().beginRemoveRows(parent, first, last)
[docs] @QtCore.pyqtSlot() def endRemoveColumns(self): super().endRemoveColumns()
[docs] @QtCore.pyqtSlot() def endRemoveRows(self): super().endRemoveRows()
[docs] @QtCore.pyqtSlot() def beginResetModel(self): super().beginResetModel()
[docs] @QtCore.pyqtSlot() def endResetModel(self): super().endResetModel()
[docs]class ModelMixin(SlotsInPythonMixin): """ A mixin for methods shared by both the base model and all proxy models. """ def _invalidateAllPersistentIndices(self): """ Invalidate all persistent indices. This method should be called before emitting a layoutChanged signal. (Alternatively, the persistent indices can be updated, but that would require quite a bit more logic. Since we're storing selection information in the model, persistent indices shouldn't be keeping track of anything important, so simply clearing them should be sufficient.) """ persistent_indices = self.persistentIndexList() all_invalid = [QtCore.QModelIndex()] * len(persistent_indices) self.changePersistentIndexList(persistent_indices, all_invalid)
[docs]class SequenceAlignmentModel(table_speed_up.MultipleRolesRoleModelMixin, ModelMixin, table_helper.RowBasedTableModel): """ A QTable model where each row corresponds to a sequence and each column corresponds to a residue position :note: If the alignment contains only zero-length sequences, then this model creates a dummy column. Otherwise, we wouldn't be able to generate any valid QModelIndex objects, which means we couldn't create any parent indices for row insertion signals. :ivar fixedColumnDataChanged: Signal emitted when the data in a fixed column is changed. Passes a tuple of the role and row index that are changing. :vartype fixedColumnDataChanged: `QtCore.pyqtSignal` emitting a tuple of (`enum.Enum`, int) :ivar rowHeightChanged: Signal emitted when the height of a row is changed is changed. :vartype rowHeightChanged: `QtCore.pyqtSignal` :cvar seqExpansionChanged: A signal emitted when sequence expansion is changed. Emitted with: - A list of all indices to be expanded or collapsed. - True if the indices should be expanded. False if they should be collapsed. :vartype seqExpansionChanged: QtCore.pyqtSignal """ domainsChanged = QtCore.pyqtSignal() predictionsChanged = QtCore.pyqtSignal() secondaryStructureChanged = QtCore.pyqtSignal() residueFormatChanged = QtCore.pyqtSignal() residueSelectionChanged = QtCore.pyqtSignal() fixedColumnDataChanged = QtCore.pyqtSignal(int, int) rowHeightChanged = QtCore.pyqtSignal() sequencesReordered = QtCore.pyqtSignal(list) textSizeChanged = QtCore.pyqtSignal() alnSetChanged = QtCore.pyqtSignal() kinaseFeaturesChanged = QtCore.pyqtSignal() kinaseConservationChanged = QtCore.pyqtSignal() hiddenSeqsChanged = QtCore.pyqtSignal(bool) seqExpansionChanged = QtCore.pyqtSignal(list, bool) sequenceStructureChanged = QtCore.pyqtSignal() MIN_ALN_QUALITY_WEIGHT = 0.2
[docs] def __init__(self, parent=None): """ :param parent: Parent of the row based table model. :type parent: `QtCore.Object` """ super().__init__(parent) self._page_model = None self._options_model = None self.consensus_seq = None self.aln = None self._split_chain_mode = True self._split_aln = None self._has_anchors = False self._column_count = 0 self._using_dummy_column = False self._ignoring_col_change = False self._structure_model = None self.initializeCustomFonts() self.setLightMode(False) self._color_scheme = { row_type: scheme() for row_type, scheme in color.DEFAULT_ROW_COLOR_SCHEMES.items() } # Fetching a color scheme out of the _color_scheme dictionary is # surprisingly slow due to the time it takes to hash an enum, so we # cache the sequence row color scheme separately to make painting # faster (and remove it from the dict to prevent accidental slow access) self._seq_color_scheme = self._color_scheme.pop(RowType.Sequence) self._nucleic_color_scheme = color.NucleicAcidColorScheme() self._brush_cache = table_speed_up.DataCache(15000) self._residue_highlights = dict() self._cache = CacheNamespace(average_color=None, res_matches_ref=None, resnum=None, quality_alpha_by_col=None, anchor_range_ends=None, anchored_ref_res_col_idxs=None, res_matches_cons=None) # make sure that the caches are cleared whenever the model changes self.dataChanged.connect(self._cache.clear) self.rowsInserted.connect(self._cache.clear) self.rowsRemoved.connect(self._cache.clear) self.rowsMoved.connect(self._cache.clear) self.columnsInserted.connect(self._cache.clear) self.columnsRemoved.connect(self._cache.clear) self.columnsMoved.connect(self._cache.clear) self.modelReset.connect(self._cache.clear) self.layoutChanged.connect(self._cache.clear)
[docs] def setStructureModel(self, smodel): self._structure_model = smodel
[docs] def setPageModel(self, page_model): """ Set the page model, which contains the options model and is also responsible for switching between split-chain and combined-chain alignments. :param page_model: The page model to set :type page_model: gui_models.PageModel """ if self._page_model: self._page_model.split_chain_viewChanged.disconnect( self._setSplitChainMode) self._page_model = page_model page_model.split_chain_viewChanged.connect(self._setSplitChainMode) self._split_chain_mode = page_model.split_chain_view self._setOptionsModel(page_model.options)
def _setOptionsModel(self, options_model): """ Set the options model for the model, which reports on various display options that the user can set through the GUI. Accessing `OptionsModel` attributes is slow enough that it affects painting speed, so we instead use an `OptionsModelCache` in this class. Note that the `OptionsModelCache` instance is read-only and must not be used to change options. :param options_model: The widget options. :type options_model: schrodinger.application.msv.gui.gui_models. OptionsModel """ self._options_model_cache = OptionsModelCache() attrs = ('antibody_cdr', 'antibody_cdr_scheme', 'color_by_aln', 'average_in_cols', 'weight_by_quality', 'colors_enabled', 'include_gaps', 'font_size', 'identity_display', 'res_format', 'binding_site_distance', 'compute_for_columns', 'pick_mode') if self._options_model is not None: # Disconnect old options model for attr_name in attrs: signal = getattr(self._options_model, f"{attr_name}Changed") signal.disconnect() self._options_model = options_model for attr_name in attrs: cur_val = getattr(self._options_model, attr_name) setattr(self._options_model_cache, attr_name, cur_val) signal = getattr(self._options_model, f"{attr_name}Changed") signal.connect( partial(setattr, self._options_model_cache, attr_name)) signal.connect(self._refreshAllData) opts = self._options_model ss = [ (opts.font_sizeChanged, self._updateFonts), (opts.identity_displayChanged, self.residueFormatChanged), (opts.res_formatChanged, self.residueFormatChanged), (opts.pick_modeChanged, self._onPickModeChanged), ] # yapf: disable for signal, slot in ss: signal.connect(slot) self._updateFonts()
[docs] def setLightMode(self, enabled): self._no_background_text_color = (color.NO_BACKGROUND_TEXT_COLOR_LM if enabled else color.NO_BACKGROUND_TEXT_COLOR)
def _genDataArgs(self, index): # See table_speed_up documentation for method documentation. # Note that the argument list in rowData must be updated to reflect any' # changes made here. row = index.row() seq = self._rows[row] col = index.column() res = seq[col] if col < len(seq) else None return [res, seq, row, col]
[docs] def rowData(self, row, cols, roles): """ Fetch data for multiple roles for multiple indices in the same row. Note that this method does minimal sanity checking of its input for performance reasons, as it is called during painting. The arguments are assumed to refer to valid indices. Use `data` instead if more sanity checking is required. :param row: The row number to fetch data for. :type row: int :param cols: A list of columns to fetch data for. :type cols: list(int) :param roles: A list of roles to fetch data for. :type roles: list(int) :return: {role: data} dictionaries for each requested column. :rtype: list(dict(int, object)) """ seq = self._rows[row] all_data = [] for cur_col in cols: res = seq[cur_col] if cur_col < len(seq) else None cur_data = {} data_args = (res, seq, row, cur_col) self._fetchMultipleRoles(cur_data, roles, *data_args) all_data.append(cur_data) return all_data
def _refreshAllData(self): """ We emit dataChanged so that the view will update the contents of the residue cells """ invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index)
[docs] def getResidueDisplayMode(self): """ :rtype: ResidueFormat :return: The residue display mode in current use """ return self._options_model_cache.res_format
[docs] @table_helper.model_reset_method def setAlignment(self, aln): """ Set the alignment model to display data from :param aln: The alignment model :type aln: gui_alignment.GuiProteinAlignment """ # As a performance optimization, we save a flag for whether or # not there are any residue anchors at all. self._has_anchors = bool(aln.getAnchoredResidues()) self._split_aln = aln self._updateSplitChainMode(aln) self._resetColumnCount()
def _updateSplitChainMode(self, aln=None): """ Update self._rows and self.aln based on the current split chain view setting. Note that self._split_aln always refers to the split chain alignment, while self.aln and self._rows always refer to the alignment for the current split-chain setting. :param aln: The split-chain or combined-chain alignment to set. If not given, the current alignment will be fetched from the page model. :type aln: gui_alignment.GuiProteinAlignment or gui_alignment.GuiCombinedChainProteinAlignment or None """ if aln is None: aln = self._page_model.aln for signal, slot in self._getAlnSignalsAndSlots(self.aln): signal.disconnect(slot) # We can't call self.loadData() because that would emit the signals for # a model reset before we change self._column_count. Instead we # manually assign aln to self._rows. We also allow self.aln as an # alias. self.aln = self._rows = aln for signal, slot in self._getAlnSignalsAndSlots(self.aln): signal.connect(slot) # Sync initial state self.onResHighlightChanged() def _resetColumnCount(self): self._column_count = self.aln.num_columns if self._column_count == 0 and len(self.aln) > 0: self._using_dummy_column = True self._column_count = 1 def _setSplitChainMode(self, enable): """ Change the split chain view setting. This method should not be called directly. Instead, OptionsModel.split_chain_view should be toggled, which will trigger a call to this method. :param enable: Whether to enable or disable split chain view. :type enable: bool """ if enable == self._split_chain_mode: return self._split_chain_mode = enable if self._split_aln is None: return with self.modelResetContext(): self._updateSplitChainMode() self._resetColumnCount() def _getAlnSignalsAndSlots(self, aln): """ :return: pairs of (aln signal, slot) :rtype: tuple(tuple) """ if aln is None: return () signals = aln.signals ss = ( (signals.sequencesAboutToBeInserted, self._sequencesAboutToBeInserted), (signals.sequencesInserted, self.endInsertRows), (signals.sequencesAboutToBeRemoved, self._sequencesAboutToBeRemoved), (signals.sequencesRemoved, self._sequencesRemoved), (signals.sequencesAboutToBeReordered, self.beginLayoutChange), (signals.sequencesReordered, self.endLayoutChange), (signals.sequencesReordered, self.sequencesReordered), (signals.alignmentAboutToBeCleared, self.beginResetModel), (signals.alignmentCleared, self.endResetModel), (signals.alignmentNumColumnsChanged, self._alignmentNumColumnsChanged), (signals.alignmentNumColumnsAboutToChange, self._alignmentNumColumnsAboutToChange), (signals.sequenceResiduesChanged, self._sequenceResiduesChanged), (signals.anchoredResiduesChanged, self._onAnchoredResiduesChanged), (signals.sequenceNameChanged, self._sequenceNameChanged), (signals.annotationTitleChanged, self._annotationTitleChanged), (signals.sequenceVisibilityChanged, self._seqVisChanged), (signals.sequenceStructureChanged, self._sequenceStructureChanged), (signals.alnSetChanged, self._onAlnSetChanged), (signals.predictionsChanged, self._onPredictionsChanged), (signals.secondaryStructureChanged, self._onSecondaryStructuresChanged), (signals.domainsChanged, self._onDomainsChanged), (signals.invalidatedDomains, self._onDomainsChanged), (signals.pfamChanged, self._onPfamChanged), (signals.kinaseFeaturesChanged, self._onKinaseFeaturesChanged), (signals.kinaseConservationChanged, self._onKinaseConservationChanged), (aln.res_selection_model.selectionChanged, self._onResidueSelectionChanged), (aln.seq_selection_model.selectionChanged, self._refreshAllData), (aln.ann_selection_model.selectionChanged, self._refreshAllData), (signals.resHighlightStatusChanged, self.onResHighlightChanged), (signals.resOutlineStatusChanged, self._refreshAllData), (signals.homologyStatusChanged, self.onHomologyStatusChanged), (signals.homologyCompositeResiduesChanged, self._refreshAllData), (signals.homologyLigandConstraintsChanged, self._refreshAllData), (signals.homologyProximityConstraintsChanged, self._refreshAllData), (signals.pairwiseConstraintsChanged, self.onPairwiseConstraintsChanged), (signals.hiddenSeqsChanged, self.hiddenSeqsChanged), (signals.seqExpansionChanged, self._onSeqExpansionChanged), ) # yapf: disable return ss def _onResidueSelectionChanged(self, added, removed): self.residueSelectionChanged.emit() # The fixed columns only need to update if we're computing selected # columns if self._options_model_cache.compute_for_columns is ColumnMode.AllColumns: return changed_residues = added | removed changed_seq_indices = set() for res in changed_residues: changed_seq_indices.add(self.aln.index(res.sequence)) roles = (CustomRole.AlignmentIdentity, CustomRole.AlignmentSimilarity, CustomRole.AlignmentConservation, CustomRole.AlignmentScore) for seq_index, role in itertools.product(changed_seq_indices, roles): self.fixedColumnDataChanged.emit(role, seq_index) def _onAnchoredResiduesChanged(self): self._has_anchors = bool(self.aln.getAnchoredResidues()) self._refreshAllData() def _onAlnSetChanged(self): self.alnSetChanged.emit() self.rowHeightChanged.emit() self._refreshAllData() def _onPfamChanged(self): self.rowHeightChanged.emit() self._refreshAllData() def _onKinaseFeaturesChanged(self): self.rowHeightChanged.emit() self.kinaseFeaturesChanged.emit() self._refreshAllData() def _onKinaseConservationChanged(self): self.rowHeightChanged.emit() self.kinaseConservationChanged.emit() self._refreshAllData() def _onSecondaryStructuresChanged(self): self.rowHeightChanged.emit() self.secondaryStructureChanged.emit() self._refreshAllData() def _onPredictionsChanged(self): self.rowHeightChanged.emit() self.predictionsChanged.emit() self._refreshAllData() def _onDomainsChanged(self): self.rowHeightChanged.emit() self.domainsChanged.emit() self._refreshAllData()
[docs] def onPairwiseConstraintsChanged(self): self._refreshAllData()
def _onSeqExpansionChanged(self, seq, expanded): seq_index = self.aln.index(seq) model_index = self.index(seq_index, 0) self.seqExpansionChanged.emit([model_index], expanded) if self._nextRowHidden(None, seq): # If the next row is hidden, then we need to notify the view that it # may need to change where the hidden sequence marker is drawn self.fixedColumnDataChanged.emit(CustomRole.NextRowHidden, seq_index)
[docs] def getAlignment(self): """ Return the underlying alignment object :return: The alignment :rtype: schrodinger.protein.alignment.BaseAlignment """ return self.aln
[docs] def sequenceCount(self): """ :rtype: int :return: The number of sequences in the alignment """ return self.rowCount()
def _seqVisChanged(self, seq, seq_index): """ Notify listeners that sequence visibility has changed. :param seq: The sequence that changed visibility :type seq: `sequence.Sequence` :param seq_index: Index of the sequence that changed visibility. :type seq_index: int """ self.fixedColumnDataChanged.emit(CustomRole.Included, seq_index)
[docs] def beginLayoutChange(self): """ Emit a layoutAboutToBeChanged signal. This helper makes disconnecting from the signal easier. """ self.layoutAboutToBeChanged.emit()
[docs] def endLayoutChange(self): """ Finish a layout change operation by clearing all persistent indices and emitting layoutChanged. """ self._invalidateAllPersistentIndices() self.layoutChanged.emit()
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return self._column_count
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation if parent is None or not parent.isValid(): return len(self._rows) else: return 0
[docs] def headerData(self, section, orientation, role=Qt.DisplayRole): # See Qt documentation for method documentation return None
@table_helper.data_method(CustomRole.Residue) def _resRole(self, res): """ Return the residue object at the location or None See table_helper for argument documentation. """ return res @table_helper.data_method(CustomRole.ReferenceResidue) def _refResRole(self, res, seq, row, col): """ Return the reference residue at the location or None See table_helper for argument documentation. """ ref_seq = self._rows[0] return ref_seq[col] if col < len(ref_seq) else None @table_helper.data_method(CustomRole.Seq) def _seqData(self, res, seq): """ Return the sequence for the specified row. See table_helper for argument documentation. """ return seq @table_helper.data_method(CustomRole.ResSelected) def _resSelectedData(self, res, seq, row, col): """ Return whether the specified residue is selected. See table_helper for argument documentation. """ if col >= len(seq): return False return self.aln.res_selection_model.isSelected(res) @table_helper.data_method(CustomRole.NonstandardRes) def _nonstandardResData(self, res, seq, row, col): """ Return whether the specified residue is nonstandard. See table_helper for argument documentation. """ if col >= len(seq): return False if not res.is_res: return False return res.type.nonstandard @table_helper.data_method(CustomRole.ResSelectionBlockStart) def _resSelectionBlockStartData(self, res, seq, row, col): """ Whether a residue selection block starts immediately before or after this index. Used to paint I-bars when in edit mode. """ sel_model = self.aln.res_selection_model if sel_model.isSelected(res): if col == 0 or not sel_model.isSelected(seq[col - 1]): return ResSelectionBlockStart.Before else: return None else: if col >= len(seq) - 1 or not sel_model.isSelected(seq[col + 1]): return None else: return ResSelectionBlockStart.After @table_helper.data_method(CustomRole.ResAnchored) def _resAnchoredData(self, res, _seq, _row, col): if not self._has_anchors: return False refseq = self.aln.getReferenceSeq() anchor_residues = self.aln.getAnchoredResidues() if res not in refseq: return res in anchor_residues else: ref_res = res return any(ref_res.idx_in_seq == anchor_res.idx_in_seq for anchor_res in anchor_residues) @property def _anchored_ref_res_col_idxs(self): if self._cache.anchored_ref_res_col_idxs is None: anchored_res = self.aln.getAnchoredResidues() ref_seq = self.aln.getReferenceSeq() anchored_ref_res = (res for res in anchored_res if res in ref_seq) anchored_ref_res_col_idxs = { res.idx_in_seq for res in anchored_ref_res } self._cache.anchored_ref_res_col_idxs = anchored_ref_res_col_idxs return self._cache.anchored_ref_res_col_idxs @table_helper.data_method(CustomRole.IsAnchoredColumnRangeEnd) def _isAnchoredColumnRangeEnd(self, _res, _seq, _row, col): return col in self._anchor_range_ends @property def _anchor_range_ends(self): if self._cache.anchor_range_ends is None: anchored_residues = self.aln.getAnchoredResidues() column_is_anchored = list(range(self.aln.num_columns)) for idx, col in enumerate(self.aln.columns()): if not any(res in anchored_residues for res in col): column_is_anchored[idx] = -1 anchored_range_ends = set() for is_anchored, col_indices in itertools.groupby( column_is_anchored, key=lambda k: k != -1): col_indices = list(col_indices) if not is_anchored: continue anchored_range_ends.add(col_indices[0]) anchored_range_ends.add(col_indices[-1]) self._cache.anchor_range_ends = anchored_range_ends return self._cache.anchor_range_ends @table_helper.data_method(CustomRole.SeqSelected) def _seqSelectedData(self, res, seq, row, col): """ Return whether the specified sequence is selected. See table_helper for argument documentation. """ return self.aln.seq_selection_model.isSelected(seq) @table_helper.data_method(CustomRole.HomologyStatus) def _homologyStatusData(self, res, seq): """ Return the homology status of the specified sequence. See table_helper for argument documentation. """ return self.aln.getHomologyStatus(seq) @table_helper.data_method(CustomRole.AlignmentIdentity, CustomRole.AlignmentSimilarity, CustomRole.AlignmentConservation, CustomRole.AlignmentScore) def _alignmentMetrics(self, res, seq, row, col, role): """ Returns alignment metric value depending on the role given. See table_helper for argument documentation. """ metrics_map = { CustomRole.AlignmentIdentity: 'getIdentity', CustomRole.AlignmentSimilarity: 'getSimilarity', CustomRole.AlignmentConservation: 'getConservation', CustomRole.AlignmentScore: 'getSimilarityScore' } reference_seq = self.aln.getReferenceSeq() if reference_seq is None: return meth_name = metrics_map[role] meth = getattr(reference_seq, meth_name) only_consider = None if self._options_model_cache.compute_for_columns == \ ColumnMode.SelectedColumns: selected_residues = self.aln.res_selection_model.getSelection() only_consider = set(reference_seq) & selected_residues return meth(seq, self._options_model_cache.include_gaps, only_consider) @table_helper.data_method(CustomRole.ReferenceSequence) def _referenceSequence(self, res, seq): """ Returns True if the given sequence is set as the reference sequence in the alignment, False otherwise. See table_helper for argument documentation. """ return self.aln.isReferenceSeq(seq) @table_helper.data_method(Qt.DisplayRole, Qt.EditRole) def _displayData(self, res, seq, row, col, role): """ Return the residue object at the location or an empty string See table_helper for argument documentation. """ if col >= len(seq): return TERMINAL_GAP elif res.is_gap: return DEFAULT_GAP display_mode = self._options_model_cache.res_format dot_mode = (self._options_model_cache.identity_display is IdentityDisplayMode.MidDot and not role == Qt.EditRole) # If we're in the process of deleting the last sequence, we'll have a # temporary dummy column after the alignment has deleted columns but # before it's deleted the sequence. In that case, the # columnHasAllSameResidues() call will fail if we don't first check # self._using_dummy_column. draw_dot = (dot_mode and not self._using_dummy_column and self.aln.columnHasAllSameResidues(col)) if display_mode is ResidueFormat.OneLetter or role == Qt.EditRole: if draw_dot: return u'\u00B7' return str(res) if display_mode is ResidueFormat.HideLetters: return "" if draw_dot: return u' \u00B7 ' return res.long_code @table_helper.data_method( *{ RoleBase.SeqAnnotationIndexes + i.value for i in SEQ_ANNO_TYPES if i is not SEQ_ANNO_TYPES.alignment_set }) def _seqAnnotationIndexesData(self, res, seq, row, col, role): ann_num = role - RoleBase.SeqAnnotationIndexes ann = self.aln.seq_annotations(ann_num) return self._page_model.getShownAnnIndexes(seq, ann) @table_helper.data_method( *{ RoleBase.SeqAnnotation + i.value for i in SEQ_ANNO_TYPES if i not in (SEQ_ANNO_TYPES.alignment_set, SEQ_ANNO_TYPES.kinase_features, SEQ_ANNO_TYPES.kinase_conservation) }) def _seqAnnotationData(self, res, seq, row, col, role): """ Return the value for the specified sequence annotation role See table_helper for argument documentation. """ ann_num = role - RoleBase.SeqAnnotation ann = self.aln.seq_annotations(ann_num) if ann in { SEQ_ANNO_TYPES.disulfide_bonds, SEQ_ANNO_TYPES.pred_disulfide_bonds }: if ann is SEQ_ANNO_TYPES.disulfide_bonds: bonds = seq.disulfide_bonds elif ann is SEQ_ANNO_TYPES.pred_disulfide_bonds: bonds = seq.pred_disulfide_bonds ss_bonds = (bond for bond in bonds if bond.is_intra_sequence) ss_bond_idxs = [ (seq.index(r1), seq.index(r2)) for r1, r2 in ss_bonds ] return ss_bond_idxs if ann is SEQ_ANNO_TYPES.antibody_cdr: return seq.annotations.getAntibodyCDRs( scheme=self._options_model_cache.antibody_cdr_scheme) if ann is SEQ_ANNO_TYPES.resnum: if col >= len(seq): return None return self.getResnumForColumn(seq, col) if ann is SEQ_ANNO_TYPES.secondary_structure: gap_indices = {i for (i, res) in enumerate(seq) if res.is_gap} return (seq.secondary_structures, gap_indices) if ann is SEQ_ANNO_TYPES.pred_secondary_structure: gap_indices = {i for (i, res) in enumerate(seq) if res.is_gap} return (seq.pred_secondary_structures, gap_indices) if ann is SEQ_ANNO_TYPES.pairwise_constraints: if seq is not self.aln.getReferenceSeq(): return [] return self.aln.pairwise_constraints.indices if ann is SEQ_ANNO_TYPES.proximity_constraints: if seq is not self.aln.getReferenceSeq(): return [] return self.aln.proximity_constraints.indexes return seq.getAnnotation(col, ann) @table_helper.data_method(RoleBase.SeqAnnotation + SEQ_ANNO_TYPES.alignment_set.value) def _alignmentSetData(self, res, seq): alignment_set = self.aln.alnSetForSeq(seq) if alignment_set is None: return None return alignment_set.name @table_helper.data_method(*list( range(RoleBase.GlobalAnnotation, RoleBase.GlobalAnnotation + MAX_ANNOTATIONS))) def _globalAnnotationData(self, res, seq, row, col, role): """ Return the Qt.DisplayData value for the specified global annotation role. See table_helper for argument documentation. """ if self._using_dummy_column: return None ann_num = role - RoleBase.GlobalAnnotation ann = self.aln.global_annotations(ann_num) data = self.aln.getGlobalAnnotationData(col, ann) if data is None: # We may be in the process of lengthening the alignment and this # column doesn't have any data in it yet. return None elif ann is ALN_ANNO_TYPES.consensus_symbols: return data.value elif ann is ALN_ANNO_TYPES.consensus_seq and data is not None: # If there is only one member in data, return it if len(data) == 1: cons_res = data[0] return cons_res if cons_res.is_gap else cons_res.short_code # Disagreement (i.e. non-consensus) is indicated by "+" else: return '+' elif ann is ALN_ANNO_TYPES.sequence_logo: # Process the sequence logo in three ways: # - Round the frequencies to reduce cache misses # - Truncate the list to MAX_AA_IN_LOGO amino acids to reduce crowding # - Convert residue objects to string bits, freqs = data rounded_freqs = tuple([(aa.short_code, round(freq, 6)) for aa, freq in freqs[:MAX_AA_IN_LOGO]]) return (bits, rounded_freqs) else: return data @table_helper.data_method(*list( range(RoleBase.SequenceProperty, RoleBase.SequenceProperty + MAX_SEQ_PROPS))) def _seqPropData(self, res, seq, row, col, role): """ Return the Qt.DisplayData value for sequence property See table_helper for argument documentation. """ prop_num = role - RoleBase.SequenceProperty seq_prop = self._options_model.sequence_properties[prop_num] return seq.getProperty(seq_prop) @table_helper.data_method(CustomRole.ResidueIndex) def _residueIndexData(self, res, seq, row, col): if self._using_dummy_column: return None return col @table_helper.data_method(CustomRole.ConsensusSeq) def _consensusSeqData(self, res, seq, row, col): """ Return the raw consensus sequence value for the given column. See table_helper for argument documentation. """ if self._using_dummy_column: return [] return self.aln.getGlobalAnnotationData(col, ALN_ANNO_TYPES.consensus_seq) @table_helper.data_method(*[ RoleBase.SeqAnnotationRange + ann.value for ann in (SEQ_ANNO_TYPES.window_hydrophobicity, SEQ_ANNO_TYPES.sasa, SEQ_ANNO_TYPES.window_isoelectric_point) ]) def _seqAnnotationRangeData(self, res, seq, row, col, role): """ Return the range for the specified sequence annotation role See table_helper for argument documentation. """ ann_num = role - RoleBase.SeqAnnotationRange ann = self.aln.seq_annotations(ann_num) values = getattr(seq.annotations, ann.name) return values.range @table_helper.data_method(RoleBase.SeqAnnotationRange + SEQ_ANNO_TYPES.b_factor.value) def _bFactorRangeData(self, res, seq, row, col, role): min_bf = (seq.annotations.min_b_factor if seq.annotations.min_b_factor < 0 else 0) max_bf = (seq.annotations.max_b_factor if seq.annotations.max_b_factor > 0 else 0) return (min_bf, max_bf) @table_helper.data_method(*[ RoleBase.GlobalAnnotationRange + ann.value for ann in (ALN_ANNO_TYPES.mean_hydrophobicity, ALN_ANNO_TYPES.mean_isoelectric_point, ALN_ANNO_TYPES.consensus_freq) ]) def _globalAnnotationRangeData(self, res, seq, row, col, role): """ Return the range for the specified global annotation role See table_helper for argument documentation. """ ann_num = role - RoleBase.GlobalAnnotationRange ann = self.aln.global_annotations(ann_num) values = getattr(self.aln.annotations, ann.name) return values.range @table_helper.data_method(CustomRole.Included) def _sequenceIncluded(self, res, seq): """ :return: Whether the sequence is included and/or visible in the workspace. :rtype: `viewconstants.Inclusion` """ return seq.visibility @table_helper.data_method(CustomRole.EntryID) def _entryID(self, res, seq): """ Sequence entry ID information """ return seq.entry_id
[docs] def getRowVisibilities(self): """ Get whether the sequences are visible in the MSV sequence list. The actual filtering is done by SequenceFilterProxyModel. """ if self.aln is None: return [] return self.aln.getSeqShownStates()
@table_helper.data_method(CustomRole.PreviousRowHidden) def _previousRowHidden(self, res, seq): """ Will the previous row get filtered out by the SequenceFilterProxyModel? Controls whether or not the AlignmentInfoView draws a hidden sequence marker between this sequence and the previous one. This data is further transformed in AnnotationProxyModel to take annotation rows into account. """ row_visibilities = self.getRowVisibilities() seq_index = self.aln.index(seq) prev_index = seq_index - 1 return prev_index >= 0 and not row_visibilities[prev_index] @table_helper.data_method(CustomRole.NextRowHidden) def _nextRowHidden(self, res, seq): """ Will the next row get filtered out by the SequenceFilterProxyModel? Controls whether or not the AlignmentInfoView draws a hidden sequence marker between this sequence and the next one. This data is further transformed in AnnotationProxyModel to take annotation rows into account. """ row_visibilities = self.getRowVisibilities() seq_index = self.aln.index(seq) next_index = seq_index + 1 return next_index < len(self.aln) and not row_visibilities[next_index] @table_helper.data_method(CustomRole.SeqExpanded) def _seqExpandedData(self, res, seq): return self.aln.isSeqExpanded(seq) @table_helper.data_method(SeqInfo.Name) def _seqNameData(self, res, seq): """ Get the (short) name of a sequence. See table_helper for argument documentation. """ return seq.name @table_helper.data_method(SeqInfo.EntryName) def _seqEntryNameData(self, res, seq): return seq.entry_name @table_helper.data_method(SeqInfo.Title) def _seqLongNameData(self, res, seq): """ Get the name of a sequence. See table_helper for argument documentation. """ return seq.long_name @table_helper.data_method(SeqInfo.GaplessLength) def _seqGaplessLengthData(self, res, seq): """ Get the gapless length of a sequence. """ return seq.getGaplessLength() @table_helper.data_method(SeqInfo.GapCount) def _seqGapCountData(self, res, seq): """ Get the gap count of a sequence. """ return seq.getGapCount() @table_helper.data_method(SeqInfo.Chain) def _seqChainData(self, res, seq): """ Get the chain of a sequence. See table_helper for argument documentation. """ return seq.chain @table_helper.data_method(CustomRole.HasStructure) def _hasStructureData(self, res, seq): """ Whether the sequence has a structure associated with it. """ return seq.hasStructure() @table_helper.data_method(CustomRole.SeqresOnly) def _seqresOnlyData(self, res, seq, row, col): """ Whether the residue is structureless. Returns True if all of the following conditions are met: - the sequence has an associated structure - the residue is not a gap - the residue only appears in the SEQRES record of the structure :rtype: bool """ if not seq.hasStructure(): return False return col < len(seq) and not res.hasStructure() and res.is_res @table_helper.data_method(CustomRole.PartialPairwiseConstraint) def _partialPairwiseConstraintData(self, res, seq, row, col): """ Whether the residue is a partial pairwise constraint :rtype: bool """ if col >= len(seq): return False pick_mode = self._options_model_cache.pick_mode if pick_mode is PickMode.Pairwise: constraints = self.aln.pairwise_constraints return (res == constraints.other_residue or res == constraints.ref_residue) elif pick_mode is PickMode.HMProximity: constraints = self.aln.proximity_constraints return res == constraints.picked_residue return False @table_helper.data_method(CustomRole.HMCompositeRegion) def _compositeRegionData(self, res): """ :return: Whether the residue is in a homology modeling composite region :rtype: bool """ return self.aln.isHomologyCompositeResidue(res) @table_helper.data_method(CustomRole.ResOutline) def _resOutlineData(self, res, seq, row, col): return self.aln.getResOutlinesForSeq(seq) @table_helper.data_method(CustomRole.SeqMatchesRefType) def _seqMatchesRefTypeData(self, res, seq, row, col): return self.aln.seqMatchesRefType(seq) @table_helper.data_method(CustomRole.PfamName) def _pfamNameData(self, res, seq): return seq.pfam_name @table_helper.data_method(Qt.BackgroundRole) def _sequenceBackgroundData(self, res, seq, row, col): """ Return the appropriate background brush for the specified residue in a sequence row. :return: A brush containing the background color or None to paint no background color :rtype: QtGui.QBrush or NoneType """ if not self._hasBackgroundColor(res, seq, row, col): return None alpha = 255 if res in self._residue_highlights: rgb = self._residue_highlights[res] elif isinstance(seq, sequence.NucleicAcidSequence): rgb = self._nucleic_color_scheme.getColorByRes(res) else: if self._options_model_cache.weight_by_quality: # "Weight Colors By Alignment Quality" sets alpha transparency # per-column alpha = self._getQualityAlphaByCol(col) if alpha == 0: # The background would be fully transparent, so there's no # point in painting anything return None rgb = self._seqBaseColor(res, seq, row, col) r, g, b = rgb rgba = (r, g, b, alpha) if rgba in self._brush_cache: return self._brush_cache[rgba] else: brush = QtGui.QBrush(QtGui.QColor(*rgba)) self._brush_cache[rgba] = brush return brush def _seqBaseColor(self, res, seq, row, col): """ Determine the color to use for the background the specified sequence row cell. This method does not take the weight-by-quality option into account. :param res: The residue for the specified cell. :type res: residue.Residue :param seq: The sequence for the specified row :type seq: sequence.Sequence :param row: The row number. :type row: int :param col: The column number. :type col: int :return: An rgb tuple representing the background color. :rtype: tuple(int, int, int) """ if self._options_model_cache.average_in_cols: # If "Average Color In Columns" is set, then all residues in a # column get the same color if not self._cache.average_color: self._updateAverageColorCache() return self._cache.average_color[col] # If none of the above options apply, then ask the color scheme for the # appropriate color. This will take into account custom colors # (including workspace residue coloring). if isinstance(self._seq_color_scheme, color.AlignmentRowColorScheme): rgb = self._seq_color_scheme.getColorByResAndAln( res, self.getAlignment()) else: rgb = self._seq_color_scheme.getColorByRes(res) return rgb def _hasBackgroundColor(self, res, seq, row, col): """ :return: Whether the given residue should not have background color """ if col >= len(seq) or res.is_gap or self._using_dummy_column: return False if not self._options_model_cache.colors_enabled: return False if res in self._residue_highlights: return True by_aln_mode = self._options_model_cache.color_by_aln if by_aln_mode is ColorByAln.Unset: return False if by_aln_mode in (ColorByAln.DifferentCons, ColorByAln.MatchingCons): # If the "Color By Sequence Alignment" option is set and this # residue doesn't match/differ from the consensus sequence, then # don't paint the background if not self._cache.res_matches_cons: self._updateResMatchesConsensus() matches = self._cache.res_matches_cons[row][col] return matches is (by_aln_mode is not ColorByAln.DifferentCons) if row != 0 and by_aln_mode in (ColorByAln.Different, ColorByAln.Matching): # If the "Color By Sequence Alignment" option is set and this # non-ref residue doesn't match/differ from the reference sequence, # then don't paint the background if not self._cache.res_matches_ref: self._updateResMatchesRef() matches = self._cache.res_matches_ref[row][col] return matches is (by_aln_mode is not ColorByAln.Different) return True def _updateResMatchesRef(self): """ Update self._cache.res_matches_ref, which stores whether a given residue matches the reference residue for that column. """ num_rows = len(self.aln) num_cols = self._column_count res_matches_ref = ([[True] * num_cols] + [[False] * num_cols for _ in range(num_rows - 1)]) ref_seq = self.aln[0] for seq_idx, seq in enumerate(self.aln[1:], start=1): if not self.aln.seqMatchesRefType(seq): continue for res_idx, (res, ref_res) in enumerate(zip(seq, ref_seq)): if (ref_res.is_res and res.is_res and ref_res.type.long_code != 'UNK' and ref_res.type.short_code == res.type.short_code): res_matches_ref[seq_idx][res_idx] = True self._cache.res_matches_ref = res_matches_ref def _updateResMatchesConsensus(self): """ Update self._cache.res_matches_cons, which stores whether a given residue matches the consensus residue for that column. If there is no consensus (e.g. multiple most common residues), all residues are considered non-matching. """ num_rows = len(self.aln) num_cols = self._column_count res_matches_cons = ([[False] * num_cols for _ in range(num_rows)]) cons_seq = [ self.aln.getGlobalAnnotationData(col, ALN_ANNO_TYPES.consensus_seq) for col in range(num_cols) ] for seq_idx, seq in enumerate(self.aln): if not self.aln.seqMatchesRefType(seq): continue for res_idx, (res, cons_residues) in enumerate(zip(seq, cons_seq)): if len(cons_residues) != 1 or res.is_gap: continue cons_res = cons_residues[0] if (cons_res.long_code != 'UNK' and cons_res.short_code == res.short_code): res_matches_cons[seq_idx][res_idx] = True self._cache.res_matches_cons = res_matches_cons def _getQualityAlphaByCol(self, col): if not self._cache.quality_alpha_by_col: self._updateQualityAlphaByCol() return self._cache.quality_alpha_by_col[col] def _updateQualityAlphaByCol(self): """ Update self._cache.quality_alpha_by_col, which stores the alpha value to use for each column when weighting colors by alignment quality. """ num_seqs = len(self.aln) min_weight = self.MIN_ALN_QUALITY_WEIGHT weight_range = 1 - min_weight alpha_by_col = [] for column in self.aln.columns(): ref_elem = next(iter(column), None) col_res = [elem for elem in column if elem.is_res] if ref_elem.is_gap or not col_res: alpha_by_col.append(0) continue match = ref_elem.short_code max_matching = sum(1 for res in col_res if res.short_code == match) if max_matching == 1: weight = 0 else: weight = (max_matching / num_seqs - min_weight) / weight_range alpha = int(weight * 255) if alpha < 0: alpha = 0 elif alpha > 255: alpha = 255 alpha_by_col.append(alpha) self._cache.quality_alpha_by_col = alpha_by_col def _updateAverageColorCache(self): """ Update self._cache.average_color, which stores the average color of all non-gap residues in a column. """ scheme = self._seq_color_scheme average_color_cache = [] for column in self.aln.columns(omit_gaps=True): if not column: average_color_cache.append(None) continue if isinstance(scheme, color.AlignmentRowColorScheme): rgbs = ( scheme.getColorByResAndAln(res, self.aln) for res in column) else: rgbs = (scheme.getColorByRes(res) for res in column) avg_rgb = [val // len(column) for val in map(sum, zip(*rgbs))] average_color_cache.append(avg_rgb) self._cache.average_color = average_color_cache @table_helper.data_method(*list( range(RoleBase.BindingSiteName, RoleBase.BindingSiteName + MAX_LIGANDS))) def _bindingSiteNameData(self, res, seq, row, col, role): ligand_idx = role - RoleBase.BindingSiteName return seq.annotations.ligands[ligand_idx] @table_helper.data_method(*list( range(RoleBase.BindingSiteBackground, RoleBase.BindingSiteBackground + MAX_LIGANDS))) def _bindingSiteBackgroundData(self, res, seq, row, col, role): scheme = self._color_scheme.get(SEQ_ANNO_TYPES.binding_sites) if scheme is None: return None ligand_idx = role - RoleBase.BindingSiteBackground if col < len(seq) and res.is_res: lig_contacts = seq.getAnnotation(col, SEQ_ANNO_TYPES.binding_sites) lig_contact = lig_contacts[ligand_idx] else: lig_contact = annotation.BINDING_SITE.NoContact return scheme.getBrushByKey(lig_contact) @table_helper.data_method(*list( range(RoleBase.KinaseConservationBackground, RoleBase.KinaseConservationBackground + MAX_LIGANDS))) def _kinaseConservationBackgroundData(self, res, seq, row, col, role): scheme = self._color_scheme.get(SEQ_ANNO_TYPES.kinase_conservation) if scheme is None: return None ligand_idx = role - RoleBase.KinaseConservationBackground conservation = None if col < len(seq) and res.is_res: conservation = res.kinase_conservation.get(ligand_idx) return scheme.getBrushByKey(conservation) @table_helper.data_method(*list( range(RoleBase.DomainName, RoleBase.DomainName + MAX_DOMAINS))) def _domainNameData(self, res, seq, row, col, role): domain_idx = role - RoleBase.DomainName return seq.annotations.domains[domain_idx] @table_helper.data_method(*list( range(RoleBase.DomainBackground, RoleBase.DomainBackground + MAX_DOMAINS))) def _domainsBackgroundData(self, res, seq, row, col, role): scheme = self._color_scheme.get(SEQ_ANNO_TYPES.domains) if scheme is None or res is None: return None domain_idx = role - RoleBase.DomainBackground domains = seq.annotations.domains if domains is None: return None if (res.is_res and res.domains is not None and domains[domain_idx] in res.domains): key = annotation.Domains.Domain else: key = annotation.Domains.NoDomain return scheme.getBrushByKey(key) # TODO: many of the "background" brushes for annotations are really # foreground brushes @table_helper.data_method( *{ RoleBase.SeqBackground + i.value for i in SEQ_ANNO_TYPES if i not in (SEQ_ANNO_TYPES.binding_sites, SEQ_ANNO_TYPES.domains, SEQ_ANNO_TYPES.kinase_conservation) }) def _annotationBackgroundData(self, res, seq, row, col, role): ann = self.aln.seq_annotations(role - RoleBase.SeqBackground) scheme = self._color_scheme.get(ann) if scheme is not None and isinstance(scheme, color.ResidueRowColorScheme): if ann is SEQ_ANNO_TYPES.kinase_features: if col >= len(seq) or res.is_gap: return None kinase_feature = seq.getAnnotation(col, ann) return scheme.getBrushByKey(kinase_feature) return scheme.getBrushByRes(res) @table_helper.data_method( *{RoleBase.GlobalBackground + i.value for i in ALN_ANNO_TYPES}) def _globalBackgroundData(self, res, seq, row, col, role): aln = self.aln ann = aln.global_annotations(role - RoleBase.GlobalBackground) scheme = self._color_scheme.get(ann) if scheme is None: return None if isinstance(scheme, color.SingleColorScheme): return scheme.getBrushByRes() cons_residues = aln.getGlobalAnnotationData( col, ALN_ANNO_TYPES.consensus_seq) if not cons_residues: # No residues present at this column in consensus sequence return scheme.getBrushByResAndAln(res, aln) else: cons_res = cons_residues[0] if isinstance(cons_res, residue.Nucleotide): return self._nucleic_color_scheme.getBrushByRes(cons_res) else: return scheme.getBrushByResAndAln(cons_residues[0], aln) @table_helper.data_method(Qt.ForegroundRole) def _foregroundData(self, res, seq, row, col): scheme = self._seq_color_scheme if self.aln.res_selection_model.isSelected(res): return color.SELECTED_TEXT_COLOR elif col >= len(seq): return scheme.TEXT_COLOR_TERM_GAP elif res.is_gap: return scheme.TEXT_COLOR_GAP elif ( not self._hasBackgroundColor(res, seq, row, col) or (self._options_model_cache.weight_by_quality and self._getQualityAlphaByCol(col) < color.NO_BACKGROUND_ALPHA_CUTOFF) ): # If there's no background color or the background opacity is low return self._no_background_text_color else: rgb = self._residue_highlights.get(res) if rgb is None and self._seq_color_scheme.custom: rgb = self._seqBaseColor(res, seq, row, col) if rgb is not None: color_options = (scheme.TEXT_COLOR, self._no_background_text_color) other_color = get_contrasting_color(rgb, options=color_options) if not isinstance(other_color, QtGui.QColor): other_color = QtGui.QColor(*other_color) return other_color return scheme.TEXT_COLOR # We only provide foreground role data for annotations that actually use it @table_helper.data_method( RoleBase.SeqForeground + SEQ_ANNO_TYPES.resnum.value, RoleBase.SeqForeground + SEQ_ANNO_TYPES.pfam.value, RoleBase.SeqForeground + SEQ_ANNO_TYPES.alignment_set.value) def _annotationForegroundData(self, res, seq, row, col, role): ann = self.aln.seq_annotations(role - RoleBase.SeqForeground) scheme = self._color_scheme[ann] if ann is SEQ_ANNO_TYPES.alignment_set: rgb = scheme.getColorByResAndAln(res, self.aln) if rgb is not None: return QtGui.QColor(*rgb) return scheme.TEXT_COLOR # We only provide foreground role data for annotations that actually use it @table_helper.data_method( *{ RoleBase.GlobalForeground + i.value for i in (ALN_ANNO_TYPES.indices, ALN_ANNO_TYPES.consensus_seq, ALN_ANNO_TYPES.consensus_symbols) }) def _annotationGlobalForegroundData(self, res, seq, row, col, role): # coloring here is based on the consensus sequence ann = self.aln.global_annotations(role - RoleBase.GlobalForeground) scheme = self._color_scheme[ann] cons_residues = self.aln.getGlobalAnnotationData( col, ALN_ANNO_TYPES.consensus_seq) if not cons_residues: # No residues in any sequence at this column; but we still need # to return a color to draw the ruler return scheme.TEXT_COLOR rgb = scheme.getColorByResAndAln(cons_residues[0], self.aln) # change brush to gray if the total brightness/value is < 100 if QtGui.qGray(*rgb) < 100: return color.NO_EMPH_TEXT_COLOR return scheme.TEXT_COLOR
[docs] @table_helper.data_method(Qt.FontRole) def getFont(self): """ :return: The current font. :rtype: QtGui.QFont """ return self._font
@table_helper.data_method( *{RoleBase.GlobalToolTip + i.value for i in ALN_ANNO_TYPES}) def _globalAnnotationToolTipData(self, res, seq, row, col, role): """ Return a tooltip string for a global annotation :param source_index: The source index for which we need a tooltip :type source_index: `QtCore.QModelIndex` :return: A tooltip for the specified cell :rtype: str """ if self._using_dummy_column: return None ann = self.aln.global_annotations(role - RoleBase.GlobalToolTip) ann_value = self.aln.getGlobalAnnotationData(col, ann) if ann is ALN_ANNO_TYPES.indices: return "Alignment Index: {}".format(ann_value) if ann is ALN_ANNO_TYPES.mean_hydrophobicity: return "Mean Hydrophobicity: {}".format(ann_value) if ann is ALN_ANNO_TYPES.mean_isoelectric_point: return "Mean Isoelectric Point: {}".format(ann_value) if ann is ALN_ANNO_TYPES.consensus_symbols: shared_text = '\n'.join([ '" ": not conserved', '"*": fully conserved', '":": strongly conserved', '".": weakly conserved' ]) return "Consensus symbol:\n{}\n{}".format(ann_value.tooltip, shared_text) if ann is ALN_ANNO_TYPES.consensus_seq: # Display using 3-letter code, if multiple residues # share the highest frequency, separate them by space. res_str = ' '.join(res_obj.long_code for res_obj in ann_value) freq = self.aln.getGlobalAnnotationData( col, ALN_ANNO_TYPES.consensus_freq) return "Consensus sequence:\n{}: {}%".format(res_str, freq) if ann is ALN_ANNO_TYPES.consensus_freq: consensus_res = self.aln.getGlobalAnnotationData( col, ALN_ANNO_TYPES.consensus_seq) res_str = ' '.join(res_obj.long_code for res_obj in consensus_res) return "Consensus frequency:\n{}: {}%".format(res_str, ann_value) if ann is ALN_ANNO_TYPES.sequence_logo: bits, aa_freq_list = ann_value tt_list = ["diversity: %.2f" % round(bits, 2)] # frequency high to low, match the drawing in the column for aa, freq in reversed(aa_freq_list): res_str = aa.long_code tt_list.append(f"{res_str}: {bits * freq:.2f} ({freq:.0%})") return '\n'.join(tt_list) @table_helper.data_method(Qt.ToolTipRole) def _toolTipData(self, res, seq, row, col, role): if col >= len(seq) or res.is_gap: return None tooltip = self._getBaseResidueToolTip(res, seq) scheme = self._seq_color_scheme if not isinstance(seq, sequence.NucleicAcidSequence ) and res not in self._residue_highlights: if isinstance(scheme, color.AlignmentRowColorScheme): scheme_key_tooltip = scheme.getColorKeyToolTipByResAndAln( res, self.aln) else: scheme_key_tooltip = scheme.getColorKeyToolTipByRes(res) tooltip += f'<br/>{scheme.display_name}: {scheme_key_tooltip}' tooltip += f'<br/>Column index: {col + 1}' tooltip += self._getTooltipImage(res) return qt_utils.wrap_qt_tag(tooltip) def _getTooltipImage(self, res): no_img = "" if (not res.type.nonstandard and res.long_code not in residue.NON_STD_AA_TT_MAP) or not res.hasStructure(): return no_img db = nonstandard_residues.get_residue_database() aa = db.getAminoAcid(res.long_code) if aa is None: return no_img mol = structure2d.get_rdmol_for_2d_rendering(aa.st) settings = sketcher.RendererSettings() settings.width = 400 settings.height = 200 renderer = sketcher.Renderer(settings) # TODO SKETCH-876 # renderer has no `clear` API so we can't store and reuse it renderer.loadStructure(mol) img = renderer.getImage() # base64 encode image to display as HTML buf = QtCore.QBuffer() try: buf.open(QtCore.QIODevice.WriteOnly) img.save(buf, "PNG", 100) b64data = bytes(buf.data().toBase64()).decode() finally: buf.close() # Scaling to half height and width to improve appearance on hidpi width = img.width() // 2 height = img.height() // 2 img = f'<img src="data:image/png;base64, {b64data}" width={width} height={height}>' return f'<hr/><div>{img}</div>' @table_helper.data_method(*list( range(RoleBase.BindingSiteToolTip, RoleBase.BindingSiteToolTip + MAX_LIGANDS))) def _bindingSiteToolTipData(self, res, seq, row, col, role): if col >= len(seq) or res.is_gap or not seq.annotations.ligands: return "" ann = SEQ_ANNO_TYPES.binding_sites ligand_idx = role - RoleBase.BindingSiteToolTip lig_contacts = seq.getAnnotation(col, ann) binding_site = lig_contacts[ligand_idx] if binding_site is annotation.BINDING_SITE.NoContact: return "" dist = self._options_model_cache.binding_site_distance.value if binding_site is annotation.BINDING_SITE.CloseContact: desc = f"within {dist - 1}Å of " elif binding_site is annotation.BINDING_SITE.FarContact: desc = f"within {dist + 1}Å of " else: assert False ligand = seq.annotations.ligands[ligand_idx] return f"{ann.title}: {desc}{ligand}" @table_helper.data_method(*list( range(RoleBase.KinaseConservationToolTip, RoleBase.KinaseConservationToolTip + MAX_LIGANDS))) def _kinaseConservationToolTipData(self, res, seq, row, col, role): if col >= len(seq) or res.is_gap or not seq.annotations.ligands: return "" ann = SEQ_ANNO_TYPES.kinase_conservation ligand_idx = role - RoleBase.KinaseConservationToolTip conservation = res.kinase_conservation.get(ligand_idx) if conservation is None: return "" ligand = seq.annotations.ligands[ligand_idx] return f"{ann.title} ({ligand}): {conservation.value}" @table_helper.data_method(*list( range(RoleBase.DomainToolTip, RoleBase.DomainToolTip + MAX_DOMAINS))) def _domainsToolTipData(self, res, seq, row, col, role): if col >= len(seq) or res.is_gap: return "" domain_idx = role - RoleBase.DomainToolTip domains = seq.annotations.domains if domains is not None: current_domain = domains[domain_idx] if res.domains is not None and current_domain in res.domains: return f"{SEQ_ANNO_TYPES.domains.title}: {current_domain}" return "" @table_helper.data_method( *{ RoleBase.SeqToolTip + i.value for i in SEQ_ANNO_TYPES if i not in { SEQ_ANNO_TYPES.alignment_set, SEQ_ANNO_TYPES.binding_sites, SEQ_ANNO_TYPES.domains, SEQ_ANNO_TYPES.kinase_conservation } }) def _seqAnnotationToolTipData(self, res, seq, row, col, role): if col >= len(seq) or res.is_gap: return None ann = self.aln.seq_annotations(role - RoleBase.SeqToolTip) tooltip = self._getBaseResidueToolTip(res, seq) if ann in (SEQ_ANNO_TYPES.disulfide_bonds, SEQ_ANNO_TYPES.pred_disulfide_bonds): # The disulfide bond will be retrieved in _getSeqAnnoToolTip ann_value = None elif ann is SEQ_ANNO_TYPES.antibody_cdr: ann_value = seq.annotations.getAntibodyCDR( col, scheme=self._options_model_cache.antibody_cdr_scheme) elif ann in (SEQ_ANNO_TYPES.pairwise_constraints, SEQ_ANNO_TYPES.proximity_constraints): return "" else: ann_value = seq.getAnnotation(col, ann) ann_info = self._getSeqAnnoToolTip(res, seq, ann_value, ann) if ann_info: tooltip += "<br>" + ann_info return qt_utils.wrap_qt_tag(tooltip) def _getSeqAnnoToolTip(self, res, seq, value, anno_type): """ Returns a string describing sequence annotation information for a given residue in a sequence. :param res: The residue for the element :type res: `schrodinger.protein.residue.Residue` :param seq: The sequence for the element :type seq: `schrodinger.protein.sequence.ProteinSequence` :param value: The value of the annotation :type value: (varies depending on annotation type) :param anno_type: The annotation type to get the tooltip for :type ann_type: `schrodinger.protein.annotation.\ ProteinSequenceAnnotations.ANNOTATION_TYPES` or `schrodinger.protein.annotation.\ ProteinAlignmentAnnotations.ANNOTATION_TYPES` """ if isinstance(seq, sequence.NucleicAcidSequence): # Since many annotations don't apply to nucleic acid sequences, we # just return an empty tooltip to avoid errors. We will hide # irrelevant annotation rows for nucleic acids in MSV-1505. return '' label = anno_type.title if anno_type is SEQ_ANNO_TYPES.b_factor and value is not None: anno_desc = "{label}: {value:0.3f}".format(label=label, value=value) return anno_desc if anno_type in (SEQ_ANNO_TYPES.secondary_structure, SEQ_ANNO_TYPES.pred_secondary_structure): if value is None: return '' res_idx = seq.index(res) ssa = seq.secondary_structures struc_type = residue.SSA_TT_MAP[value] if anno_type == SEQ_ANNO_TYPES.pred_secondary_structure: ssa = seq.pred_secondary_structures struc_type = f"<b>Prediction:</b><br>{struc_type}" # Get the corresponding SSA limits for this particular residue ssa_starts = [ssa_.limits[0] for ssa_ in ssa] ssa_idx = bisect.bisect(ssa_starts, res_idx) - 1 start_index, end_index = ssa[ssa_idx].limits return f"{struc_type} {residue.get_formatted_residue_range(seq[start_index], seq[end_index])}" if anno_type in (SEQ_ANNO_TYPES.disulfide_bonds, SEQ_ANNO_TYPES.pred_disulfide_bonds): return self._getDisulfideBondToolTip(anno_type, res) if anno_type is SEQ_ANNO_TYPES.antibody_cdr: cdr = value if cdr.label is annotation.AntibodyCDRLabel.NotCDR: return '' return f"CDR {cdr.label.name}: {residue.get_formatted_residue_range(seq[cdr.start], seq[cdr.end])}" if anno_type is SEQ_ANNO_TYPES.kinase_features: kinase_feature = value if kinase_feature is None or kinase_feature is annotation.KinaseFeatureLabel.NO_FEATURE: return '' def find_boundary(forward=True): # find the start and end of this kinase feature end = len(seq) if forward else -1 step = 1 if forward else -1 prev_res = res for ind in range(res.idx_in_seq, end, step): cur_res = seq[ind] if cur_res.is_gap: continue if cur_res.kinase_features != res.kinase_features: break prev_res = cur_res return prev_res start_res = find_boundary(False) end_res = find_boundary(True) return f'{kinase_feature.value}: {residue.get_formatted_residue_range(start_res, end_res)}' if (anno_type is SEQ_ANNO_TYPES.window_hydrophobicity or anno_type is SEQ_ANNO_TYPES.window_isoelectric_point): # Show the annotation's raw value at the residue key = anno_type.name[len("window_"):] raw_val = getattr(res, key) raw_anno_desc = "{label}: {value:0.3f}".format(label=label, value=raw_val) # Show the annotation's windowed average, if it exists windowed_anno_desc = "" if value is not None: if anno_type is SEQ_ANNO_TYPES.window_hydrophobicity: window_padding = seq.annotations.hydrophobicity_window_padding elif anno_type is SEQ_ANNO_TYPES.window_isoelectric_point: window_padding = seq.annotations.isoelectric_point_window_padding window_size = 2 * window_padding + 1 windowed_anno_desc = ( "\n{label} in {n}-residue window: {value:0.3f}".format( label=label, n=window_size, value=value)) return raw_anno_desc + windowed_anno_desc if anno_type in (SEQ_ANNO_TYPES.pred_accessibility, SEQ_ANNO_TYPES.pred_disordered, SEQ_ANNO_TYPES.pred_domain_arr): if value is None: return label value = value.name.lower().capitalize() return f'<b>Prediction:</b><br>{label}: {value}' value = residue.CB_TT_MAP.get(value, value) anno_desc = "{label}: {value}".format(label=label, value=value) return anno_desc def _getDisulfideBondToolTip(self, anno, res): if anno is SEQ_ANNO_TYPES.disulfide_bonds: bond = res.disulfide_bond elif anno is SEQ_ANNO_TYPES.pred_disulfide_bonds: bond = res.pred_disulfide_bond else: raise ValueError('`anno` should be either `disulfide_bond` or ' 'pred_disulfide_bond.') if not bond: return '' label = anno.title if bond.res_pair[0] == res: partner_res = bond.res_pair[1] else: partner_res = bond.res_pair[0] # Note: returns None if partner is deleted or inter-sequence if bond.isValid() and bond.is_intra_sequence: return f"<b>Prediction:</b><br>{label} with {partner_res.long_code}{partner_res.resnum}{partner_res.inscode}" else: return "" @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.binding_sites.value) def _bindingSiteRowHeightScaleData(self, res, seq): return 1 if seq.annotations.ligands else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.domains.value) def _domainsRowHeightScaleData(self, res, seq): return 1 if seq.annotations.domains else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.disulfide_bonds.value) def _disulfideBondsRowHeightScaleData(self, res, seq): return 1 if len(seq.disulfide_bonds) > 0 else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pred_disulfide_bonds.value) def _predDisulfideBondsRowHeightScaleData(self, res, seq): return 1 if len(seq.pred_disulfide_bonds) > 0 else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pred_disordered.value) def _predDisorderedRegionsRowHeightScaleData(self, res, seq): return seq.hasDisorderedRegionsPredictions() @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pred_domain_arr.value) def _predArrangementRowHeightScaleData(self, res, seq): return seq.hasDomainArrangementPredictions() @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pred_accessibility.value) def _predAccessibilityRowHeightScaleData(self, res, seq): return seq.hasSolventAccessibility() @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pfam.value) def _pfamHeightScaleData(self, res, seq): return not all(p is None for p in seq.annotations.pfam) @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.antibody_cdr.value) def _antibodyCdrRowHeightScaleData(self, res, seq): return 1 if seq.annotations.isAntibodyChain() else 0 @table_helper.data_method( *{ RoleBase.SeqRowHeightScale + i.value for i in (SEQ_ANNO_TYPES.kinase_features, SEQ_ANNO_TYPES.kinase_conservation) }) def _kinaseFeatureRowHeightScaleData(self, res, seq): return 1 if seq.isKinaseChain() else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pred_secondary_structure.value) def _predSecondaryStructureRowHeightScaleData(self, res, seq): return seq.hasSSAPredictions() @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.secondary_structure.value) def _secondaryStructureRowHeightScaleData(self, res, seq): secondary_strucs = seq.secondary_structures if (len(secondary_strucs) == 1 and secondary_strucs[0].ssa_type is None): return 0 else: return 1 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.alignment_set.value) def _alignmentSetRowHeightScaleData(self, res, seq): if self.aln.alnSetForSeq(seq) is None: return 0 else: return 1 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.pairwise_constraints.value) def _pairwiseConstraintRowHeightScaleData(self, res, seq): return 1 if seq is self.aln.getReferenceSeq() else 0 @table_helper.data_method(RoleBase.SeqRowHeightScale + SEQ_ANNO_TYPES.proximity_constraints.value) def _proximityConstraintRowHeightScaleData(self, res, seq): return 1 if seq is self.aln.getReferenceSeq() else 0 @table_helper.data_method(CustomRole.ChainDivider) def _chainDividerData(self, res, seq, row, col): """ If split chain mode is disabled and this residue is the start of a new chain, return the color to use for the chain divider. Otherwise, return None. :rtype: QtGui.QColor or None """ if self._split_chain_mode or col not in seq.chain_offsets: return None elif self.aln.res_selection_model.isSelected(res): return self._seq_color_scheme.SEL_CHAIN_DIVIDER_COLOR else: return self._seq_color_scheme.CHAIN_DIVIDER_COLOR
[docs] def flags(self, index): """ See Qt documentation for method documentation """ return Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled
@table_helper.data_method(CustomRole.ColSelected) def _colSelectedData(self, res, seq, row, col): """ Whether all residues in a column have been selected. """ selection = self.aln.res_selection_model.getSelection() column = { elem for elem in self.aln.getColumn(col) if elem and not elem.is_gap } return column.issubset(selection) def _setData(self, col, seq, value, role, row_num): """ See table_helper for argument documentation. """ if role == CustomRole.ReferenceSequence: if value: self.aln.setReferenceSeq(seq) else: self.aln.setReferenceSeq(None) return True elif role == CustomRole.Included: seq.visibility = value return True elif role == CustomRole.ReplacementEdit: # value is a SeqSliceReplacement object to_replace = seq[col:col + value.num_to_replace] old_txt = "".join( " " if res.is_gap else str(res) for res in to_replace) if value.new_residues == old_txt: # Don't do anything if the single letter codes are the same so # that we don't lose any information (like three-letter residue # names for residues with single-letter codes of "X") return True mutation = (row_num, col, col + value.num_to_replace, list(value.new_residues)) self.aln.mutateResidues(*mutation, select=True) return True elif role == CustomRole.InsertionEdit: self.aln.addElements(seq, col, list(value), select=True) return True elif role == CustomRole.SeqExpanded: self.aln.setSeqExpanded(seq, value) # sequence expansion can affect data in the fixed columns but not in # the alignment view, so we don't need to emit a dataChanged signal # here. fixedColumnDataChanged will be emitted in # _onSeqExpansionChanged if it's needed. return self.NO_DATA_CHANGED return False
[docs] def annotationTypes(self): """ Get the current annotation types :return: A tuple of: - The global annotation enum - The sequence annotation enum :rtype: tuple """ try: return self.aln.global_annotations, self.aln.seq_annotations except AttributeError: return [], []
[docs] def resetAnnotation(self, ann): for seq in self.aln: seq.annotations.resetAnnotation(ann)
[docs] def strucTitles(self): """ Get all structure titles :return: A list containing all structure titles :rtype: list """ struc_names = [] for idx, seq in enumerate(self.aln, start=1): struc_names.append( seq.name if seq.name else 'Sequence {0}'.format(idx)) return struc_names
def _alignmentNumColumnsAboutToChange(self, old_length, new_length): """ Respond to the alignmentNumColumnsAboutToChange signal by getting ready to insert or remove columns :param old_length: The current length of the alignment :type old_length: int :param new_length: The new length of the alignment :type new_length: int """ index = QtCore.QModelIndex() new_using_dummy_column = None if old_length == 0: # We're inserting "real" columns, which means we can get rid of our # dummy column old_length = self._column_count new_using_dummy_column = False if new_length == 0 and len(self.aln) > 0: # We're removing all "real" columns but the alignment still has # sequences in it, so we need to add a dummy column. new_using_dummy_column = True new_length = 1 if new_length < old_length: self.beginRemoveColumns(index, new_length, old_length - ZERO_INDEXED) elif new_length > old_length: self.beginInsertColumns(index, old_length, new_length - ZERO_INDEXED) else: # We've swapped a real column for a dummy column or vice versa, so # we're not actually changing the number of columns self._ignoring_col_change = True if new_using_dummy_column is not None: self._using_dummy_column = new_using_dummy_column def _alignmentNumColumnsChanged(self, old_length, new_length): """ Respond to the alignmentNumColumnsChanged signal by finishing inserting or removing columns :param old_length: The previous length of the alignment :type old_length: int :param new_length: The current length of the alignment :type new_length: int """ if self._ignoring_col_change: # We swapped a real column for a dummy column or vice versa, so # we're not actually changing the number of columns self._ignoring_col_change = False return elif new_length == 0 and len(self.aln) > 0: # We've removed all "real" columns but the alignment still has # sequences in it, so we need to add a dummy column. self._column_count = 1 else: self._column_count = new_length if new_length < old_length: self.endRemoveColumns() elif new_length > old_length: self.endInsertColumns() def _sequencesAboutToBeInserted(self, first_seq_index, last_seq_index): """ Respond to the sequencesAboutToBeInserted signal by inserting a dummy column if needed and then emitting rowsAboutToBeInserted. :param first_seq_index: The index of the first inserted sequence. :type first_seq_index: int :param last_seq_index: The index of the last inserted sequence. :type last_seq_index: int """ invalid_index = QtCore.QModelIndex() # If the alignment length was changing, the alignment would have already # emitted alignmentNumColumnsAboutToChange and alignmentNumColumnsChanged, so if # self._column_count is still 0 we know that all inserted sequences are # zero length, which means that we need to insert a dummy column. if self._column_count == 0: self.beginInsertColumns(invalid_index, 0, 0) self._column_count = 1 self._using_dummy_column = True self.endInsertColumns() self.beginInsertRows(invalid_index, first_seq_index, last_seq_index) def _sequencesAboutToBeRemoved(self, first_seq_index, last_seq_index): """ Respond to the sequencesAboutToBeRemoved signal by emitting rowsAboutToBeRemoved. :param first_seq_index: The index of the first removed sequence. :type first_seq_index: int :param last_seq_index: The index of the last removed sequence. :type last_seq_index: int """ self.beginRemoveRows(QtCore.QModelIndex(), first_seq_index, last_seq_index) def _sequencesRemoved(self, first_seq_index, last_seq_index): """ Respond to the sequencesRemoved signal by emitting rowsRemoved and removing the dummy column if needed. :param first_seq_index: The index of the first removed sequence. :type first_seq_index: int :param last_seq_index: The index of the last removed sequence. :type last_seq_index: int """ self.endRemoveRows() if self._using_dummy_column and len(self.aln) == 0: # We just removed the last sequences from the alignment, so we no # longer need the dummy column invalid_index = QtCore.QModelIndex() self.beginRemoveColumns(invalid_index, 0, 0) self._column_count = 0 self._using_dummy_column = False self.endRemoveColumns() def _sequenceResiduesChanged(self): """ Response to changes within a sequence by updating the appropriate cells """ self._refreshAllData() def _sequenceNameChanged(self, seq): """ Respond to a sequence name changing by updating the appropriate header row :param seq: The sequence that changed names :type seq: `schrodinger.protein.sequence.Sequence` """ row = self.aln.index(seq) self.fixedColumnDataChanged.emit(CustomRole.RowTitle, row) self.fixedColumnDataChanged.emit(CustomRole.ChainCol, row) def _annotationTitleChanged(self, seq): """ Respond to an annotation title changing by updating the appropriate header row :param seq: The sequence whose annotation changed names :type seq: `schrodinger.protein.sequence.Sequence` """ row = self.aln.index(seq) self.fixedColumnDataChanged.emit(CustomRole.RowTitle, row) self.rowHeightChanged.emit() # If the number of ligands changes def _sequenceStructureChanged(self, seq, seq_index): """ Respond to a sequence structure changing by updating the appropriate cells. :param seq: The sequence whose structure changed :type seq: `schrodinger.protein.sequence.Sequence` :param seq_index: The index of the sequence whose structure changed :type seq_index: int """ self.sequenceStructureChanged.emit() # Note that this emits dataChanged for *all* the cells in the row; if # this proves to be too slow we can optimize. left = self.index(seq_index, 0) right = self.index(seq_index, len(seq) - 1) self.dataChanged.emit(left, right)
[docs] def onHomologyStatusChanged(self, seq): row = self.aln.index(seq) self.fixedColumnDataChanged.emit(CustomRole.HomologyStatus, row)
[docs] def setResSelectionState(self, selection, selected, current=False): """ Mark the residues specified by `selection` as either selected or deselected. :param selection: A selection containing the entries to update. :type selection: `QtCore.QItemSelection` :param selected: Whether the residues should be selected (True) or deselected (False). :type selected: bool :param current: Whether this selection change should only affect the "current" selection. Note that "current" here means "the portion of the selection that's in the process of being updated," i.e., the selection that's from the mouse click (or click and drag) that we're currently in the middle of. This is equivalent to passing the `QItemSelectionModel::Current | QItemSelectionModel::Clear` flags to `QItemSelectionModel::select`. :type current: bool """ if self._options_model.pick_mode is PickMode.HMBindingSite: return residues = set() for index in selection.indexes(): row, col = index.row(), index.column() seq = self.aln[row] if col >= len(seq): continue res = seq[col] residues.add(res) self._handleResSelection(residues, selected, current)
[docs] def setResRangeSelectionState(self, from_index, to_index, selected, columns, current=False): """ Mark all residues between `from_index` and `to_index` as either selected or deselected. :param from_index: The first index to select or deselect. :type from_index: QtCore.QModelIndex :param to_index: The last index to select or deselect. :type to_index: QtCore.QModelIndex :param selected: Whether the residues should be selected (True) or deselected (False). :type selected: bool :param columns: Whether all residues in the specified columns should be selected or deselected. :type columns: bool :param current: Whether these selection changes should only affect the "current" selection. Note that "current" here means "the portion of the selection that's in the process of being updated," i.e., the selection that's from the mouse click (or click and drag) that we're currently in the middle of. This is equivalent to passing the `QItemSelectionModel::Current | QItemSelectionModel::Clear` flags to `QItemSelectionModel::select`. :type current: bool """ if not from_index.isValid() or not to_index.isValid(): return if columns: seqs = self.aln else: from_row = from_index.row() to_row = to_index.row() if to_row < from_row: to_row, from_row = from_row, to_row seqs = (self.aln[i] for i in range(from_row, to_row + 1)) from_col = from_index.column() to_col = to_index.column() if to_col < from_col: to_col, from_col = from_col, to_col residues = set() for seq in seqs: residues.update(seq[from_col:to_col + 1]) self._handleResSelection(residues, selected, current)
def _onPickModeChanged(self, mode): """ :type mode: picking.PickMode """ if (mode is PickMode.HMChimera and not self.aln.homology_composite_residues): picking.handle_reset_pick(self.aln, mode) self._refreshAllData() def _handleResSelection(self, residues, selected, current=False): """ :param residues: Change the state of these residues :type residues: set[residue.Residue] :param selected: Whether to select or deselect the residues :type selected: bool :param current: Whether these selection changes should only affect the "current" selection. This only has an effect when not in picking mode. Note that "current" here means "the portion of the selection that's in the process of being updated," i.e., the selection that's from the mouse click (or click and drag) that we're currently in the middle of. This is equivalent to passing the `QItemSelectionModel::Current | QItemSelectionModel::Clear` flags to `QItemSelectionModel::select`. :type current: bool """ if self._structure_model: split_aln = self._page_model.split_aln residues = list(residues) if (residues and isinstance(residues[0], residue.CombinedChainResidueWrapper)): residues = [res.split_res for res in residues] residues = self._structure_model.mapResidues(residues) residues = [res for res in residues if res.sequence in split_aln] pick_mode = self._options_model.pick_mode if pick_mode is not None: picking.handle_pick(self.aln, pick_mode, residues, selected) elif current: self.aln.res_selection_model.setCurrentSelectionState( residues, selected) else: self.aln.res_selection_model.setSelectionState(residues, selected)
[docs] def setSeqSelectionState(self, selection, selected): seqs = {self.aln[index.row()] for index in selection.indexes()} self.aln.seq_selection_model.setSelectionState(seqs, selected)
[docs] def clearResSelection(self): self.aln.res_selection_model.clearSelection()
[docs] def handleBindingSitePick(self, index, ligand_idx): pick_mode = self._options_model.pick_mode if pick_mode is not PickMode.HMBindingSite: return res, seq, row, col = self._genDataArgs(index) lig_contacts = seq.getAnnotation(col, SEQ_ANNO_TYPES.binding_sites) lig_contact = lig_contacts[ligand_idx] if lig_contact is annotation.BINDING_SITE.NoContact: return aln = self.getAlignment() lig = seq.annotations.ligands[ligand_idx] picking.handle_pick(aln, pick_mode, res, lig)
[docs] def handleProximityPick(self, index): pick_mode = self._options_model.pick_mode if pick_mode is not PickMode.HMProximity: return res, *_ = self._genDataArgs(index) aln = self.getAlignment() picking.handle_pick(aln, pick_mode, res)
[docs] def expandSelectionToAnnotationValues(self, anno, ann_index): cdr_scheme = None if anno is SEQ_ANNO_TYPES.antibody_cdr: cdr_scheme = self._options_model_cache.antibody_cdr_scheme aln = self.getAlignment() aln.expandSelectionToAnnotationValues(anno, ann_index, cdr_scheme)
[docs] def getIndexForRes(self, res): """ Get the model index for the given residue. :param res: A residue :type res: schrodinger.protein.residue.Residue :return: The model index of the residue :rtype: QModelIndex """ seq = res.sequence return self.index(self.aln.index(seq), seq.index(res))
[docs] def getSelectedResIndices(self): """ Get indices for all selected residues. :rtype: list[QtCore.QModelIndex] """ selected = self.aln.res_selection_model.getSelectionIndices() return [self.index(seq_i, res_i) for (seq_i, res_i) in selected]
[docs] def isWorkspaceAln(self): """ :return: Whether this model represents the workspace alignment. :rtype: bool """ try: return self.aln.isWorkspace() except AttributeError: # The alignment isn't an undoable alignment, so it doesn't have an # isWorkspace method return False
[docs] def getResnumForColumn(self, seq, col_index): """ Get cached, filtered display resnum for column. :param seq: The sequence :type seq: schrodinger.protein.sequence.Sequence :param col_index: The index of the column to display :type col_index: int :return: Formatted resnum for display :rtype: str or None """ if self._cache.resnum is None: self._cache.resnum = IdDict() seq_resnums = self._cache.resnum.get(seq) if seq_resnums is None: seq_resnums = list(self._filterResnumData(seq)) self._cache.resnum[seq] = seq_resnums return seq_resnums[col_index]
@classmethod def _filterResnumData(cls, seq): """ Keep residue numbers that are divisible by RESNUM_INCR and at least RESNUM_INCR-1 residues apart. :param seq: The sequence :type seq: schrodinger.protein.sequence.Sequence :return: Residue numbers for display :rtype: generator(str or None) """ min_resnum_spacing = RESNUM_INCR - 1 # Counter to track when a resnum was last shown - incremented so it can # return True in the first loop last_shown_idx = min_resnum_spacing + 1 for res in seq: display_resnum = None if (res.is_res and res.resnum is not None and res.resnum % RESNUM_INCR == 0 and last_shown_idx > min_resnum_spacing): # Reset idx and format resnum for display last_shown_idx = 0 display_resnum = str(res.resnum) yield display_resnum last_shown_idx += 1 def _defaultFont(self): """ Return the default font to be used. :return Default font :rtype: `QtGui.QFont` """ return QtGui.QFont("Objective", weight=QtGui.QFont.Medium) def _updateFonts(self): """ Create a font object in the current font size, and notify the view that font size has changed. """ self._font = self._defaultFont() self._font.setPointSize(self._options_model_cache.font_size) invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) self.textSizeChanged.emit() def _getBaseResidueToolTip(self, res, seq): """ Return a string containing residue and sequence information :param res: The residue for the element :type res: `schrodinger.protein.residue.Residue` :param seq: The sequence for the element :type seq: `schrodinger.protein.sequence.ProteinSequence` :rtype: str :return: Residue and sequence information """ if res.chain: seq_chain_info = f"{seq.name} - Chain {res.chain}" else: seq_chain_info = seq.name res_info = f"{res.long_code} {res.resnum}{res.inscode}" tooltip = f"{res_info} ({seq_chain_info})" if seq.hasStructure() and res.is_res and not res.hasStructure(): tooltip += " (structureless)" if res.long_code in residue.NON_STD_AA_TT_MAP: tooltip = f"<p style='display:inline;white-space:pre;'>{tooltip}<i> / {residue.NON_STD_AA_TT_MAP[res.long_code]}</i>" return tooltip
[docs] def updateColorScheme(self, row_type, scheme): """ Used to set the color scheme of a specific row type. """ if isinstance(scheme, color.PositionScheme): scheme.setLength(self.getAlignment().num_columns) scheme = copy.deepcopy(scheme) if row_type is RowType.Sequence: # Consensus sequence is always the same color scheme as sequence self._color_scheme[ALN_ANNO_TYPES.consensus_seq] = scheme # update caches self._seq_color_scheme = scheme self._cache.average_color = None else: self._color_scheme[row_type] = scheme self._refreshAllData()
[docs] def getSeqColorScheme(self): """ :return: The sequence color scheme currently in use :rtype: color.AbstractRowColorScheme """ return copy.deepcopy(self._seq_color_scheme)
[docs] def updateResidueColors(self, key_to_color_map): """ Update the colors of residues in the sequence rows. :param key_to_color_map: A map from residue keys to colors. Each color is represented by a tuple of (r, g, b) values. :type key_to_color_map: dict(residue.ResidueKey, tuple(int, int, int)) """ # Convert color map from keying by residue info to keying by residue res_to_color_map = {} for seq in self.aln: for res in seq.residues(): key = res.getKey() color = key_to_color_map.get(key) if color is not None: res_to_color_map[res] = color seq_scheme = self._seq_color_scheme seq_scheme.updateCustomResColors(res_to_color_map) self._refreshAllData()
[docs] def getResidueColors(self): """ Get the color of residues in the sequence rows :return: The colors of each residue in the MSV. Each residue is represented by a `residue.ResidueKey` and each color is represented by a tuple of (r, g, b) values. :rtype key_to_color_map: dict(residue.ResidueKey, tuple(int, int, int)) """ color_map = {} for row, seq in enumerate(self.aln): if not seq.hasStructure(): continue for col, res in enumerate(seq): if res.is_gap: continue key = res.getKey() if key is None: continue bbrush = self._sequenceBackgroundData(res, seq, row, col) if bbrush is not None: r, g, b, _ = bbrush.color().getRgb() color_map[key] = (r, g, b) return color_map
[docs] def onResHighlightChanged(self): self._residue_highlights = self.aln.getHighlightColorMap() self._refreshAllData()
[docs] def moveSelectionBelow(self, index): """ Move all selected sequences immediately below the specified index. Used for drag-and-drop. :param proxy_index: The index to move selected sequences below. :type proxy_index: QtCore.QModelIndex """ if not index.isValid(): raise RuntimeError("Invalid drop destination") after_seq = self.aln[index.row()] self.aln.moveSelectedSequences(after_seq)
[docs] def getAdjacentIndexForEditing(self, index, direction): """ Return a new index next to the specified index in the given direction. An invalid index will be returned if:: - No such index exists (i.e. asking for a residue above the first sequence in the alignment) - The new index refers to a structured residue (and therefore isn't editable) - The new index refers to a position that's past the end of a sequence (and therefore isn't editable) :param index: The starting index :type index: QtCore.QModelIndex :param direction: Which direction to go in :type direction: Adjacent :return: The new index :rtype: QtCore.QModelIndex """ row = index.row() col = index.column() if direction is Adjacent.Up: row -= 1 elif direction is Adjacent.Down: row += 1 elif direction is Adjacent.Left: col -= 1 elif direction is Adjacent.Right: col += 1 else: raise ValueError("Invalid direction") if row < 0 or col < 0: return QtCore.QModelIndex() try: seq = self.aln[row] res = seq[col] except IndexError: return QtCore.QModelIndex() if seq.hasStructure() and res.hasStructure(): return QtCore.QModelIndex() return self.index(row, col)
[docs] def isSingleBlockSelected(self): """ Is a single block of residues (i.e. contiguous residues/gaps from one sequence or multiple adjacent sequences) selected? :rtype: bool """ return self.aln.res_selection_model.isSingleBlockSelected()
[docs] def alnSetResSelected(self): """ Whether any selected residues are in sequences in an alignment set :rtype: bool """ return self.aln.alnSetResSelected()
[docs] def initializeCustomFonts(self): """ Initializes custom fonts used in the viewmodel """ custom_font_paths = [ ":/msv/fonts/Objective-Black-Italic.otf", ":/msv/fonts/Objective-Black.otf", ":/msv/fonts/Objective-Bold-Italic.otf", ":/msv/fonts/Objective-Bold.otf", ":/msv/fonts/Objective-ExtraBold-Italic.otf", ":/msv/fonts/Objective-ExtraBold.otf", ":/msv/fonts/Objective-Italic.otf", ":/msv/fonts/Objective-Light-Italic.otf", ":/msv/fonts/Objective-Light.otf", ":/msv/fonts/Objective-Medium-Italic.otf", ":/msv/fonts/Objective-Medium.otf", ":/msv/fonts/Objective-Regular.otf", ":/msv/fonts/Objective-Super-Italic.otf", ":/msv/fonts/Objective-Super.otf", ":/msv/fonts/Objective-Thin-Italic.otf", ":/msv/fonts/Objective-Thin.otf", ] global _font_added if not _font_added: for font_path in custom_font_paths: success = QtGui.QFontDatabase.addApplicationFont(font_path) if success == -1: raise RuntimeError( f"Custom font could not be loaded at {font_path}") _font_added = True
[docs]class OptionsModelCache(object): """ An object used to store display options that `SequenceAlignmentModel` needs access to. See `SequenceAlignmentModel._setOptionsModel` for additonal information. This class is required because we cannot set attributes on instances of `object`, as instances of that class do not have a `__dict__` attribute. Subclasses of `object` don't have that restriction. """
# This class intentionally left blank
[docs]class DropProxyMixin: """ A mixin for proxies involved in drag-and-drop. """
[docs] def moveSelectionBelow(self, proxy_index): """ Move all selected sequences immediately below the specified index. Used for drag-and-drop. :param proxy_index: The index to move selected sequences below. :type proxy_index: QtCore.QModelIndex """ source_index = self.mapToSource(proxy_index) self.sourceModel().moveSelectionBelow(source_index)
[docs]class MouseOverPassthroughMixin: """ A mixin for proxies involved in mouse-over state. """
[docs] def setMouseOverIndex(self, proxy_index): source_index = (self.mapToSource(proxy_index) if proxy_index is not None else None) self.sourceModel().setMouseOverIndex(source_index)
[docs] def setContextOverIndex(self, proxy_index): source_index = (self.mapToSource(proxy_index) if proxy_index is not None else None) self.sourceModel().setContextOverIndex(source_index)
[docs] def isMouseOverIndex(self, proxy_index): source_index = self.mapToSource(proxy_index) return self.sourceModel().isMouseOverIndex(source_index)
[docs]class AnnotationSelectionPassthroughMixin:
[docs] def setAnnSelectionState(self, proxy_index, selected): source_index = self.mapToSource(proxy_index) self.sourceModel().setAnnSelectionState(source_index, selected)
[docs]class GetAlignmentProxyMixin: """ A mixin for proxies that fetches the alignment through their source model. This proxy also caches the source model in Python to avoid a C++ call when calling sourceModel(). Used in both the fixed and scrollable columns. """
[docs] def __init__(self, *args, **kwargs): self._source_model = None super().__init__(*args, **kwargs)
[docs] def setSourceModel(self, model): # See QAbstractItemView documentation for method documentation super().setSourceModel(model) self._source_model = model
[docs] def sourceModel(self): # See QAbstractItemView documentation for method documentation return self._source_model
[docs] def getAlignment(self): """ Return the underlying alignment :return: The alignment if the source model exists; else None :rtype: schrodinger.protein.alignment.BaseAlignment """ if self._source_model is not None: return self._source_model.getAlignment()
[docs]class SeqExpansionProxyMixin: """ A mixin for tree models that transmit sequence expansion information to their view. Used in both the fixed and scrollable columns. """ seqExpansionChanged = QtCore.pyqtSignal(list, bool)
[docs] def setSourceModel(self, model): # See QAbstractItemView documentation for method documentation old_model = self.sourceModel() signals = {'seqExpansionChanged': self._seqExpansionChanged} if old_model is not None: table_helper.disconnect_signals(old_model, signals) super(SeqExpansionProxyMixin, self).setSourceModel(model) table_helper.connect_signals(model, signals)
def _seqExpansionChanged(self, source_indices, expanded): """ Pass along a seqExpansionChanged signal from the source model after mapping the indices to be expanded or collapsed. :param source_indices: A list of all source model indices (`QtCore.QModelIndex`) to expand or collapse. :type source_indices: list :param expanded: True if the group should be expanded. False if it should be collapsed. :type expanded: bool """ proxy_indices = list(map(self.mapFromSource, source_indices)) self.seqExpansionChanged.emit(proxy_indices, expanded)
[docs]class ProxyMixin(SeqExpansionProxyMixin, DropProxyMixin, GetAlignmentProxyMixin, table_speed_up.MultipleRolesRoleProxyPassthroughMixin): """ A mixin class that provides functionality common to all the annotation proxies (but not the fixed column proxies). We also cache the sourceModel to avoid going through the C++ layer. Note that this mixin should be listed first in the parents for the proxy classes that use it. :ivar fixedColumnDataChanged: Signal emitted when the data in a fixed column is changed. Passes a tuple of the role and row index that are changing. :vartype fixedColumnDataChanged: `QtCore.pyqtSignal` emitting a tuple of (`enum.Enum`, int) """ residueFormatChanged = QtCore.pyqtSignal() residueSelectionChanged = QtCore.pyqtSignal() fixedColumnDataChanged = QtCore.pyqtSignal(int, int) rowHeightChanged = QtCore.pyqtSignal() textSizeChanged = QtCore.pyqtSignal() predictionsChanged = QtCore.pyqtSignal() secondaryStructureChanged = QtCore.pyqtSignal() domainsChanged = QtCore.pyqtSignal() alnSetChanged = QtCore.pyqtSignal() kinaseFeaturesChanged = QtCore.pyqtSignal() kinaseConservationChanged = QtCore.pyqtSignal() sequenceStructureChanged = QtCore.pyqtSignal()
[docs] def getIndexForRes(self, res): """ Get the model index for the given residue. :param res: A residue :type res: schrodinger.protein.residue.Residue :return: The model index of the residue :rtype: QModelIndex """ source_index = self.sourceModel().getIndexForRes(res) return self.mapFromSource(source_index)
[docs] def setResSelectionState(self, selection, selected, current=False): """ See `SequenceAlignmentModel.setResSelectionState` for method documentation. """ source_selection = self.mapSelectionToSource(selection) self.sourceModel().setResSelectionState(source_selection, selected, current)
[docs] def setResRangeSelectionState(self, from_index, to_index, selected, columns, current=False): """ See `SequenceAlignmentModel.setResRangeSelectionState` for method documentation. """ from_source_index = self.mapToSource(from_index) to_source_index = self.mapToSource(to_index) self.sourceModel().setResRangeSelectionState(from_source_index, to_source_index, selected, columns, current)
[docs] def setSeqSelectionState(self, selection, selected): """ Mark the sequences specified by `selection` as either selected or deselected. :param selection: A selection containing the entries to update. :type selection: QtCore.QItemSelection :param selected: Whether the entries should be selected (True) or deselected (False). :type selected: bool """ source_selection = self.mapSelectionToSource(selection) self.sourceModel().setSeqSelectionState(source_selection, selected)
[docs] def clearResSelection(self): self.getAlignment().res_selection_model.clearSelection()
[docs] def clearSeqSelection(self): self.getAlignment().seq_selection_model.clearSelection()
[docs] def getResSelection(self): return self.getAlignment().res_selection_model.getSelection()
[docs] def getSelectedResIndices(self): """ Get indices for all selected residues. :rtype: list[QtCore.QModelIndex] """ source_indices = self.sourceModel().getSelectedResIndices() return list(map(self.mapFromSource, source_indices))
[docs] def getSingleLetterCodeForSelectedResidues(self): """ Get the single letter code for all selected residues. This method assumes that a single block of residues from a single sequence is selected. :rtype: str """ selection = self.getAlignment().res_selection_model.getSelection() selection = sorted(selection, key=lambda res: res.idx_in_seq) return "".join(" " if res.is_gap else str(res) for res in selection)
[docs] def handlePick(self, proxy_index): source_index = self.mapToSource(proxy_index) self.sourceModel().handlePick(source_index)
[docs] def expandSelectionToAnnotationValues(self, anno, ann_index): self.sourceModel().expandSelectionToAnnotationValues(anno, ann_index)
[docs] def setSourceModel(self, model): # See Qt documentation for argument documentation old_model = self.sourceModel() signals = { 'residueFormatChanged': self.residueFormatChanged, 'residueSelectionChanged': self.residueSelectionChanged, 'fixedColumnDataChanged': self._sourceFixedColumnDataChanged, 'rowHeightChanged': self.rowHeightChanged, 'textSizeChanged': self.textSizeChanged, 'predictionsChanged': self.predictionsChanged, 'kinaseFeaturesChanged': self.kinaseFeaturesChanged, 'kinaseConservationChanged': self.kinaseConservationChanged, 'secondaryStructureChanged': self.secondaryStructureChanged, 'alnSetChanged': self.alnSetChanged, 'domainsChanged': self.domainsChanged, 'sequenceStructureChanged': self.sequenceStructureChanged, } if old_model is not None: table_helper.disconnect_signals(old_model, signals) table_helper.connect_signals(model, signals) self.sequenceCount = model.sequenceCount self.annotationTypes = model.annotationTypes self.resetAnnotation = model.resetAnnotation super().setSourceModel(model)
[docs] def getResidueDisplayMode(self): """ Return the current residue display mode setting. :return: The current residue display mode. :rtype: `enum.Enum` """ return self.sourceModel().getResidueDisplayMode()
@QtCore.pyqtSlot(int, int) def _sourceFixedColumnDataChanged(self, role, source_row): """ Emit fixedColumnDataChanged in response to receiving the same signal from the source model. :param role: The role that data has changed for. :type role: int :param source_row: The source row that data has changed for. :type source_row: int """ source_index = self.sourceModel().index(source_row, 0) proxy_index = self.mapFromSource(source_index) proxy_row = proxy_index.row() if proxy_row >= 0: self.fixedColumnDataChanged.emit(role, proxy_row)
[docs] def isWorkspaceAln(self): """ :return: Whether this model represents the workspace alignment. :rtype: bool """ return self.sourceModel().isWorkspaceAln()
[docs] def getFont(self): """ :return: The current font. :rtype: QtGui.QFont """ return self.sourceModel().getFont()
[docs] def getAdjacentIndexForEditing(self, proxy_index, direction): """ See `SequenceAlignmentModel.getAdjacentIndexForEditing` for method documentation. """ source_index = self.mapToSource(proxy_index) source_adjacent = self.sourceModel().getAdjacentIndexForEditing( source_index, direction) return self.mapFromSource(source_adjacent)
[docs] def isSingleBlockSelected(self): """ Is a single block of residues (i.e. contiguous residues/gaps from one sequence or multiple adjacent sequences) selected? :rtype: bool """ return self.sourceModel().isSingleBlockSelected()
[docs] def alnSetResSelected(self): """ Whether any selected residues are in sequences in an alignment set :rtype: bool """ return self.sourceModel().alnSetResSelected()
# Data structures used to record information about row insertions and removals # in NestedProxy. _ROW_INDEL_FIELDS = ("parent_row", "parent_int_id", "start", "end") RowInsertionInfo = collections.namedtuple("RowInsertionInfo", _ROW_INDEL_FIELDS) RowRemovalInfo = collections.namedtuple("RowRemovalInfo", _ROW_INDEL_FIELDS)
[docs]class NestedProxy(ModelMixin, QtCore.QAbstractProxyModel): """ A base class for proxy models that contain one level of nesting. The internal ID of indices will be either `TOP_LEVEL` or the row number of the parent row. Note that QAbstractItemModel assumes that internal IDs are pointers to objects that represent the parent row. As such, it never updates internal IDs when updating persistent model indices. Because of this, this class reimplements beginInsertRows, endInsertRows, beginRemoveRows, and endRemoveRows to correctly update internal IDs. These methods aren't virtual in QAbstractItemModel, but they're only called from Python in all NestedProxy subclasses so our reimplementations get called instead of the QAbstractItemModel implementations. This reimplementation follows the general pattern of the QAbstractItemModel implementation in order to minimize differences with QAbstractItemModel and hopefully avoid any potential issues caused by the change. As such, we update the persistent indices in endInsertRows/endRemoveRows instead of beginInsertRows/beginRemoveRows, and we keep a stack of row insertion/removal requests. In theory, this allows for nested row insertions and removals. In practice, Qt's support for nested row insertions and removals is spotty. QSortFilterProxyModels don't support them, and there's no documentation on the various limitations and requirements when using other models and proxies. Properly handling nested row insertions and removals also tends to increase in complexity with the depth of the proxy stack, so we intentionally avoid them in all of the MSV viewmodels. """
[docs] def __init__(self, parent=None): super().__init__(parent) # The stack of row insertion and deletion information self._indel_stack = []
[docs] def index(self, row, column, parent=None): # See Qt documentation for method documentation if not (0 <= column < self.columnCount() and 0 <= row < self.rowCount(parent)): return QtCore.QModelIndex() if parent is None or not parent.isValid(): internal_id = TOP_LEVEL elif parent.internalId() != TOP_LEVEL: return QtCore.QModelIndex() else: internal_id = parent.row() return self.createIndex(row, column, internal_id)
[docs] def parent(self, index): # See Qt documentation for method documentation if not index.isValid(): return QtCore.QModelIndex() internal_id = index.internalId() if internal_id == TOP_LEVEL: return QtCore.QModelIndex() else: return self.createIndex(internal_id, 0, TOP_LEVEL)
[docs] def hasChildren(self, parent): # See Qt documentation for method documentation return self.rowCount(parent) > 0
[docs] def buddy(self, index): # See Qt documentation for method documentation return index
[docs] def beginInsertRows(self, parent, start, end): # See Qt documentation for method documentation assert 0 <= start <= end info = RowInsertionInfo(parent.row(), parent.internalId(), start, end) self._indel_stack.append(info) self.rowsAboutToBeInserted.emit(parent, start, end)
[docs] def beginRemoveRows(self, parent, start, end): # See Qt documentation for method documentation assert 0 <= start <= end info = RowRemovalInfo(parent.row(), parent.internalId(), start, end) self._indel_stack.append(info) self.rowsAboutToBeRemoved.emit(parent, start, end)
[docs] def endInsertRows(self): # See Qt documentation for method documentation info = self._indel_stack.pop() assert isinstance(info, RowInsertionInfo) inserted_count = info.end - info.start + 1 self._endIndelRows(info, inserted_count, self.rowsInserted)
[docs] def endRemoveRows(self): # See Qt documentation for method documentation info = self._indel_stack.pop() assert isinstance(info, RowRemovalInfo) delta = -(info.end - info.start + 1) self._endIndelRows(info, delta, self.rowsRemoved)
def _endIndelRows(self, info, delta, signal): """ Update persistent indices before emitting rowsInserted or rowsRemoved. :param info: The RowInsertionInfo or RowRemovalInfo object describing the insertion or removal. :type info: RowInsertionInfo or RowRemovalInfo :param delta: The number of rows that were inserted or removed. Should be negative if rows were removed. :type delta: int :param signal: The signal to emit. :type signal: QtCore.pyqtSignal """ new_pindices = [] old_pindices = [] for pindex in self.persistentIndexList(): new_pindex = None pindex_row = pindex.row() pindex_int_id = pindex.internalId() if pindex_row < 0: # The persistent index is already invalid continue if info.parent_row < 0: group_matches = pindex_int_id == TOP_LEVEL else: group_matches = pindex_int_id == info.parent_row if group_matches: # This persistent index is in the same group that the indel # happened in. if delta < 0 and info.start <= pindex_row <= info.end: # This persistent index is one of the rows that was removed new_pindex = QtCore.QModelIndex() elif pindex_row >= info.start: # This persistent index is after the indel, so we have to # update the row number of index new_pindex = self.createIndex(pindex_row + delta, pindex.column(), pindex_int_id) elif info.parent_row < 0 and pindex_int_id != TOP_LEVEL: # The indel was into the top level and this persistent index is # a child row if delta < 0 and info.start <= pindex_int_id <= info.end: # This persistent index's parent row was removed new_pindex = QtCore.QModelIndex() elif info.start <= pindex_int_id: # This persistent index's parent row is after the indel, so # we have to update the internal id new_pindex = self.createIndex(pindex_row, pindex.column(), pindex_int_id + delta) if new_pindex is not None: old_pindices.append(pindex) new_pindices.append(new_pindex) if old_pindices: self.changePersistentIndexList(old_pindices, new_pindices) if info.parent_row < 0: parent = QtCore.QModelIndex() else: parent = self.createIndex(info.parent_row, 0, info.parent_int_id) signal.emit(parent, info.start, info.end)
# Lists of sequence numbers used in AnnotationProxyModel when rows are # grouped by annotation. `all` contains sequence numbers of all sequences. # `structured_only` contains sequence numbers only for those sequences that # have an associated structure. SequenceNums = collections.namedtuple("SequenceNums", ("all", "structured_only"))
[docs]class SequenceInfo(object): """ Information about a single sequence. Used in `AnnotationProxyModel`. """
[docs] def __init__(self, has_struc=None, anns=None): """ :param has_struc: Whether the sequence has an associated structure. May be `None` while the sequence is in the process of being added. :type has_struc: bool or NoneType :param anns: A list of the currently shown sequence annotations for this sequence. Only populated when `AnnotationProxyModel` is grouping rows by sequence. Will be `None` if the rows are grouped by annotation. :type anns: list or NoneType """ self.has_struc = has_struc if anns is None: anns = [] self.anns = anns
[docs]class GroupByAnnotationInfo(object): """ Information about a sequence annotation. Used in `AnnotationProxyModel`. """
[docs] def __init__(self, ann, seq_numbers, ann_indexes=None): """ :param ann: The annotation type :type ann: `enum.Enum` :param seq_numbers: A list of sequences (represented by an integer index) to display for this annotation. :type seq_numbers: list :param ann_indexes: A tuple of tuples of annotation indexes for multi-value annotations (may have multiple rows per sequence) or None for standard annotations (one row per sequence). :type ann_indexes: tuple[tuple[int]] or NoneType """ self._source_row_map = None self._ann = ann self.seqs = seq_numbers self._ann_indexes = ann_indexes
@property def ann(self): return self._ann @property def ann_indexes(self): return self._ann_indexes @ann_indexes.setter def ann_indexes(self, value): self._ann_indexes = value self._source_row_map = None
[docs] def __len__(self): """ Return the number of rows needed to display this annotation for all sequences. For standard annotations, equal to the number of sequences. """ if self.ann_indexes is None: return len(self.seqs) else: return sum(len(sublist) for sublist in self.ann_indexes)
[docs] def getSourceRowInfo(self, proxy_row): """ Map `proxy_row` to the correct source row and annotation index. :return: source row and multi-row annotation index. Annotation index will be None for standard annotations. :rtype: tuple(int, int or NoneType) """ assert proxy_row >= 0 if self.ann_indexes is None: return (self.seqs[proxy_row], None) else: if self._source_row_map is None: source_row_map = [] for _source_row_idx, ann_indexes in enumerate(self.ann_indexes): for _ann_idx in ann_indexes: source_row_map.append((_source_row_idx, _ann_idx)) self._source_row_map = source_row_map source_row_idx, ann_idx = self._source_row_map[proxy_row] return (self.seqs[source_row_idx], ann_idx)
[docs] def getProxyRowStart(self, source_row): """ Return the proxy row number representing the first row *after* any proxy rows corresponding to the given source row. This is used when a new source sequence is being inserted after source_row (which must already be present in `self.seqs`). This method will return the proxy index where insertion will start. :rtype: int """ if self.ann_indexes is None: return source_row source_row_idx = self.seqs.index(source_row) proxy_row_start = 0 for idx in range(source_row_idx + 1): ann_idxs = self.ann_indexes[idx] proxy_row_start += len(ann_idxs) return proxy_row_start
[docs]class PerRowFlagCacheProxyMixin(table_speed_up.AbstractFlagCacheProxyMixin): """ A mixin to cache flags on a per-row basis instead of per-cell (which is what `table_speed_up.FlagCacheProxyMixin` does) since flags are the same across rows here. :note: This mixin assumes that all indices in the same row have the same internal ID. This is true for any NestedProxy subclasses, but is *not* generally true for Qt tree models. """
[docs] def flags(self, index): # See Qt documentation for method documentation index_hashable = (index.row(), index.internalId()) try: return self._flag_cache[index_hashable] except KeyError: flag = super(PerRowFlagCacheProxyMixin, self).flags(index) self._flag_cache[index_hashable] = flag return flag
[docs]class SequenceFilterProxyModel(PerRowFlagCacheProxyMixin, ProxyMixin, NestedProxy): """ A proxy model to hide certain sequences. Used by "Find sequence in list." """
[docs] def __init__(self, parent=None): super().__init__(parent) self._row_visibilities = None self._source_proxy_indexes = None self._proxy_source_indexes = None self._row_count = 0 self._column_count = 0 self._is_active = False self._invalidate_row_timer = QtCore.QTimer(self) self._invalidate_row_timer.setSingleShot(True) self._invalidate_row_timer.timeout.connect(self._invalidateRowFilter)
[docs] def setActive(self, active): if not active: self._invalidate_row_timer.stop() refresh = active and self._is_active is False self._is_active = active if refresh: self._invalidateRowFilter()
def _onHiddenSeqsChanged(self): self._cacheRowVisibilities() self._invalidate_row_timer.stop() # Prevent double call self._invalidateRowFilter()
[docs] @table_helper.model_reset_method def setSourceModel(self, model): super().setSourceModel(model) # Passthrough signals model.columnsAboutToBeInserted.connect(self.beginInsertColumns) model.columnsInserted.connect(self.endInsertColumns) model.columnsAboutToBeRemoved.connect(self.beginRemoveColumns) model.columnsRemoved.connect(self.endRemoveColumns) model.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged) model.modelAboutToBeReset.connect(self.beginResetModel) # Slots model.modelReset.connect(self._onSourceModelReset) model.dataChanged.connect(self._onSourceDataChanged) model.layoutChanged.connect(self._onSourceLayoutChanged) # Can ignore rowsAboutToBeInserted because this model does a delayed # reset whenever rows are inserted model.rowsInserted.connect(self._onSourceRowsInserted) model.rowsAboutToBeRemoved.connect(self._onSourceRowsAboutToBeRemoved) model.rowsRemoved.connect(self._onSourceRowsRemoved) model.fixedColumnDataChanged.connect(self._onFixedColumnDataChanged) model.hiddenSeqsChanged.connect(self._onHiddenSeqsChanged) self._initializeBookkeeping()
# # # # # # # # # # # # # # # # # # # # # # # # Implementations of Qt pure virtual methods # # # # # # # # # # # # # # # # # # # # # # #
[docs] def mapFromSource(self, source_index): if not source_index.isValid(): return QtCore.QModelIndex() proxy_row = self._source_proxy_indexes[source_index.row()] if proxy_row is None: return QtCore.QModelIndex() return self.index(proxy_row, source_index.column())
[docs] def mapToSource(self, proxy_index): if not proxy_index.isValid(): return QtCore.QModelIndex() source_row = self._proxy_source_indexes[proxy_index.row()] return self.sourceModel().index(source_row, proxy_index.column())
[docs] def columnCount(self, parent=None): return self._column_count
[docs] def rowCount(self, parent=None): if parent is None or not parent.isValid(): return self._row_count else: return 0
# # # # # # # # # # # # # # # # # # # # # # # # Implementations of MSV pure virtual methods # # # # # # # # # # # # # # # # # # # # # # #
[docs] def rowData(self, proxy_row, cols, roles): source_row = self._proxy_source_indexes[proxy_row] return self.sourceModel().rowData(source_row, cols, roles)
# # # # # # # # # # # # # # # # # # # # # # # # Reimplementations of Qt virtual methods # # # # # # # # # # # # # # # # # # # # # # #
[docs] def endInsertColumns(self): # See Qt documentation for method documentation self._column_count = self.sourceModel().columnCount() super().endInsertColumns()
[docs] def endRemoveColumns(self): # See Qt documentation for method documentation self._column_count = self.sourceModel().columnCount() super().endRemoveColumns()
# # # # # # # # # # # # # # # # # # # # # # # # Slots for Qt signals # # # # # # # # # # # # # # # # # # # # # # # def _onSourceModelReset(self): self._initializeBookkeeping() self.endResetModel() def _onSourceLayoutChanged(self): self._initializeBookkeeping() self._invalidateAllPersistentIndices() self.layoutChanged.emit() def _onSourceDataChanged(self, top_left, bottom_right): if self._invalidate_row_timer.isActive(): return if not top_left.isValid() and not bottom_right.isValid(): self.dataChanged.emit(top_left, bottom_right) return top_row, top_col = top_left.row(), top_left.column() bottom_row, bottom_col = bottom_right.row(), bottom_right.column() top_row = self._source_proxy_indexes[top_row] proxy_top_left = self.index(top_row, top_col) bottom_row = self._source_proxy_indexes[bottom_row] proxy_bottom_right = self.index(bottom_row, bottom_col) self.dataChanged.emit(proxy_top_left, proxy_bottom_right) def _onSourceRowsInserted(self, parent, start, end): self._onFixedColumnDataChanged() def _onSourceRowsAboutToBeRemoved(self, parent, start, end): proxy_start, proxy_end = self._getAcceptedRowRange(start, end) if proxy_start is None: return self.beginRemoveRows(parent, proxy_start, proxy_end) def _onSourceRowsRemoved(self, parent, start, end): proxy_start, proxy_end = self._getAcceptedRowRange(start, end) if proxy_start is None: return del self._row_visibilities[start:end + 1] self._cacheSourceProxyIndexes() self.endRemoveRows() # # # # # # # # # # # # # # # # # # # # # # # # Private helper methods # # # # # # # # # # # # # # # # # # # # # # # def _getAcceptedRowRange(self, start, end): accepted_children = self._proxy_source_indexes if not accepted_children: return None, None proxy_start = bisect.bisect_left(accepted_children, start) if proxy_start == len(accepted_children): return None, None proxy_end = bisect.bisect_right(accepted_children, end) - 1 if proxy_end == -1 or proxy_end < proxy_start: return None, None return proxy_start, proxy_end def _onFixedColumnDataChanged(self): self._initializeBookkeeping() if self._is_active: self._invalidate_row_timer.start() def _invalidateRowFilter(self): self.beginResetModel() self.endResetModel() def _initializeBookkeeping(self): """ Clear and re-populate all of the bookkeeping data needed for this proxy. """ source_model = self.sourceModel() self._column_count = source_model.columnCount() self._cacheRowVisibilities() def _cacheRowVisibilities(self): source_model = self.sourceModel() self._row_visibilities = source_model.getRowVisibilities() self._cacheSourceProxyIndexes() def _cacheSourceProxyIndexes(self): source_proxy_indexes = [] proxy_source_indexes = [] seen_visible = 0 for source_index, vis in enumerate(self._row_visibilities): if vis: proxy_index = seen_visible proxy_source_indexes.append(source_index) seen_visible += 1 else: proxy_index = None source_proxy_indexes.append(proxy_index) self._source_proxy_indexes = source_proxy_indexes self._proxy_source_indexes = proxy_source_indexes self._row_count = seen_visible
[docs]class AnnotationProxyModel(PerRowFlagCacheProxyMixin, table_speed_up.MultipleRolesRoleProxyMixin, ProxyMixin, NestedProxy): """ A proxy model that creates children rows for currently displayed annotations and adds a spacer row in between sequences. This proxy can be toggled between grouping rows by sequence and grouping rows by annotation. :cvar groupByChanged: A signal emitted when the model is toggled between group-by-sequence and group-by-annotation. Emitted with the new group-by setting (`GroupBy`). :vartype groupByChanged: `QtCore.pyqtSignal` :cvar tableWidthChangedSignal: A signal emitted when the table width is changed. This signal is emitted in response to the view calling `tableWidthChanged`. :vartype tableWidthChangedSignal: `QtCore.pyqtSignal` :cvar ROLE_MAPPINGS: A dictionary for mapping Qt roles from view roles to `SequenceAlignmentModel` roles. Because `SequenceAlignmentModel` doesn't have separate rows for annotations, it uses different role numbers to differentiate between sequences, sequence annotations, and global annotations. This dictionary is formatted as {view role: (role for sequences, role base for sequence annotations, role base for global annotations)}. See `_mappedRole` for additional information. :vartype ROLE_MAPPINGS: dict(int, tuple(int or None, int, int)) :cvar ROW_HEIGHT_SCALE_ANNS: Sequence annotations for which we should request `CustomRole.RowHeightScale` data from `SequenceAlignmentModel`. A row height scale of 1 will be returned for all other row types. :vartype ROW_HEIGHT_SCALE_ANNS: tuple(int) :cvar NO_GLOBAL_DATA_ROLES: Roles that should not return any data for global annotation rows. Otherwise, global annotation rows will be mapped to the 0th row in `SequenceAlignmentModel` (i.e. the reference sequence row) and data will be fetched as normal. :vartype NO_GLOBAL_DATA_ROLES: set(int) :ivar _all_global_ann: An enum containing all global annotations present in the alignment. If no source model has been set, is an empty list. :vartype _all_global_ann: `enum.Enum` or list :ivar _all_seq_ann: An enum containing all sequence annotations present in the alignment. If no source model has been set, is an empty list. :vartype _all_seq_ann: `enum.Enum` or list :ivar _all_structureless_seq_ann: All sequence annotations present in the alignment that don't require a structure. :vartype _all_structureless_seq_ann: list :ivar _shown_global_ann: A list of all global annotations that are currently displayed. Annotations are listed in the order that they are displayed. :vartype _shown_global_ann: list :ivar _shown_row_types: A set of all global and sequence annotations that are currently displayed. :vartype _shown_row_types: set :ivar _seq_info: A list of sequences in the table. Each sequence is represented by a `SequenceInfo` object. The `SequenceInfo.has_struc` values are always populated for each sequence. The `SequenceInfo.ann` values are only populated when the table is grouped by sequence. They are `None` when the table is grouped by annotation. :vartype _seq_info: list :ivar _group_by_ann_info: A list of annotations currently displayed in the table. Each annotation is represented by a `GroupByAnnotationInfo` object. This value is only populated when the table is grouped by annotation. It is `None` when the table is grouped by sequence. :vartype _group_by_ann_info: list :ivar _group_by_ann_seq_nums: A `SequenceInfo` object containing sequence numbers for the annotations that do and do not require a structure. Note that the `GroupByAnnotationInfo.seqs` values in `_group_by_ann_info` point to the sequence number lists in this object. As such, sequence numbers for all annotations can be updated by updating this object. This value is only populated when the table is grouped by annotation. It is `None` when the table is grouped by sequence. :type param: `SequenceInfo` :note: Data methods in this class (i.e. methods decorated with `@table_helper.data_method`) may take up to five arguments: ann_type If the row represents an annotation, an `AnnotationType` enum representing the annotation type. If the row represents a sequence or a spacer, a `RowType` enum representing the row type. ann If the row represents an annotation, an `annotation._AnnotationEnum` enum. None otherwise. ann_index If the row represents a multi-value annotation, an int representing the annotation index. None otherwise. source_index The `QModelIndex` object for the source model index that corresponds to the index that data is being requested for. source_model The source model. multiple_roles_data A {role: data} dictionary of potentially relevant data fetched from the source model. Data methods that take fewer than five arguments will be called with the first N arguments from the above list. Note that data methods can be called in three different ways, which will result in different combinations of arguments being populated: - Via an `index.data(role)` or `model.data(index, role)` call. In this case, `data` will call the relevant data method and `multiple_roles_data` will be None. - Via a `model.data(index, CustomRole.MultipleRoles, multiple_roles)` call. In this case, `_multipleRolesData` will call the relevant data methods and source_index and source_model will be None. If a data method needs data from the source model, `_mapRolesToSource` should be updated so that data for the appropriate roles are fetched. - Via a `rowData` call. As with the `_multipleRolesData` call, source_index and source_model will be None, and if a data method needs data from the source model, `_mapRolesToSource` should be updated so that data for the appropriate roles are fetched. """ groupByChanged = QtCore.pyqtSignal(object) tableWidthChangedSignal = QtCore.pyqtSignal(int) ROLE_MAPPINGS = { Qt.DisplayRole: (Qt.DisplayRole, RoleBase.SeqAnnotation, RoleBase.GlobalAnnotation), CustomRole.DataRange: (None, RoleBase.SeqAnnotationRange, RoleBase.GlobalAnnotationRange), Qt.BackgroundRole: (Qt.BackgroundRole, RoleBase.SeqBackground, RoleBase.GlobalBackground), Qt.ForegroundRole: (Qt.ForegroundRole, RoleBase.SeqForeground, RoleBase.GlobalForeground), Qt.ToolTipRole: (Qt.ToolTipRole, RoleBase.SeqToolTip, RoleBase.GlobalToolTip), } MULTI_ROW_ANN_BACKGROUND_ROLE_BASE = { SEQ_ANNO_TYPES.binding_sites: RoleBase.BindingSiteBackground, SEQ_ANNO_TYPES.kinase_conservation: RoleBase.KinaseConservationBackground, SEQ_ANNO_TYPES.domains: RoleBase.DomainBackground, } MULTI_ROW_ANN_TOOLTIP_ROLE_BASE = { SEQ_ANNO_TYPES.binding_sites: RoleBase.BindingSiteToolTip, SEQ_ANNO_TYPES.kinase_conservation: RoleBase.KinaseConservationToolTip, SEQ_ANNO_TYPES.domains: RoleBase.DomainToolTip, } ROW_HEIGHT_SCALE_ANNS = ( SEQ_ANNO_TYPES.alignment_set, SEQ_ANNO_TYPES.binding_sites, SEQ_ANNO_TYPES.kinase_conservation, SEQ_ANNO_TYPES.domains, SEQ_ANNO_TYPES.disulfide_bonds, SEQ_ANNO_TYPES.antibody_cdr, SEQ_ANNO_TYPES.kinase_features, SEQ_ANNO_TYPES.pairwise_constraints, SEQ_ANNO_TYPES.secondary_structure, SEQ_ANNO_TYPES.pfam, SEQ_ANNO_TYPES.pred_accessibility, SEQ_ANNO_TYPES.pred_domain_arr, SEQ_ANNO_TYPES.pred_disordered, SEQ_ANNO_TYPES.pred_disulfide_bonds, SEQ_ANNO_TYPES.pred_secondary_structure, SEQ_ANNO_TYPES.proximity_constraints, ) NO_GLOBAL_DATA_ROLES = { CustomRole.EntryID, CustomRole.HasStructure, CustomRole.Included, CustomRole.ResSelected, CustomRole.Seq }
[docs] def __init__(self, parent=None): # See Qt documentation for argument documentation super().__init__(parent) self._options_model = None self._all_global_ann = [] self._all_seq_ann = [] self._all_structureless_seq_ann = [] self._shown_global_ann = [] self._shown_row_types = set() self._seq_info = [] self._group_by_ann_info = None self._group_by_ann_seq_nums = None self._column_count = 0 self._show_alignment_set = False self._mouse_over_index = None self._context_over_index = None self._show_spacers = True self._show_spacers_before_row = -1 # The last seq should not have a spacer row, but it will temporarily # have a spacer row if sequences are being added or removed at the end # of the alignment self._last_seq_has_spacer = False # make sure that we clear the flags cache as soon as we finish inserting # or removing a row self.rowsInserted.connect(self._flag_cache.clear) self.rowsRemoved.connect(self._flag_cache.clear)
[docs] def setOptionsModel(self, options_model): """ Set the options model for the model, which reports on various display options that the user can set through the GUI. Accessing `OptionsModel` attributes is slow enough that it affects painting speed, so we instead use an `OptionsModelCache` in this class. Note that the `OptionsModelCache` instance is read-only and must not be used to change options. :param options_model: The widget options. :type options_model: schrodinger.application.msv.gui.gui_models. OptionsModel """ self._options_model_cache = OptionsModelCache() attrs = ('group_by',) if self._options_model is not None: # Disconnect old options model for attr_name in attrs: signal = getattr(self._options_model, f"{attr_name}Changed") signal.disconnect() self._options_model = options_model for attr_name in attrs: cur_val = getattr(self._options_model, attr_name) setattr(self._options_model_cache, attr_name, cur_val) signal = getattr(self._options_model, f"{attr_name}Changed") signal.connect( partial(setattr, self._options_model_cache, attr_name)) ss = [ (self._options_model.group_byChanged, self._updateGroupBy), (self._options_model.all_visible_annotationsChanged, self._updateVisibleRowTypes), (self._options_model.annotation_spacer_enabledChanged, self._updateShowSpacer), ] for signal, slot in ss: signal.connect(slot) slot()
[docs] @table_helper.model_reset_method def setSourceModel(self, model): og_model = self.sourceModel() if og_model is not None: for signal, slot in self._getSourceModelSignalsAndSlots(og_model): signal.disconnect(slot) # See Qt documentation for method documentation super().setSourceModel(model) for signal, slot in self._getSourceModelSignalsAndSlots(model): signal.connect(slot) self._fetchAnnotationTypes() self._initializeBookkeeping()
def _getSourceModelSignalsAndSlots(self, model): return [ (model.modelAboutToBeReset, self.beginResetModel), (model.modelReset, self._sourceModelReset), (model.layoutAboutToBeChanged, self.layoutAboutToBeChanged), (model.layoutChanged, self._sourceLayoutChanged), (model.rowsAboutToBeInserted, self._sourceRowsAboutToBeInserted), (model.rowsInserted, self._sourceRowsInserted), (model.rowsAboutToBeRemoved, self._sourceRowsAboutToBeRemoved), (model.rowsRemoved, self._sourceRowsRemoved), (model.columnsAboutToBeInserted, self.beginInsertColumns), (model.columnsInserted, self.endInsertColumns), (model.columnsAboutToBeRemoved, self.beginRemoveColumns), (model.columnsRemoved, self.endRemoveColumns), (model.dataChanged, self._modelDataChanged), (model.alnSetChanged, self._alnSetChanged), (model.kinaseFeaturesChanged, self._invalidateFilter), (model.kinaseConservationChanged, self._invalidateFilter), (model.secondaryStructureChanged, self._invalidateFilter), (model.predictionsChanged, self._invalidateFilter), (model.sequenceStructureChanged, self._onSequenceStructureChanged), (model.domainsChanged, self._invalidateFilter) ] # yapf: disable @QtCore.pyqtSlot() def _sourceModelReset(self): self._fetchAnnotationTypes() self._initializeBookkeeping() self.endResetModel() @QtCore.pyqtSlot() def _sourceLayoutChanged(self): self._initializeBookkeeping() self._invalidateAllPersistentIndices() self.layoutChanged.emit() def _fetchAnnotationTypes(self): """ When we get a new model or the model is reset, fetch the enums for annotation types """ model = self.sourceModel() self._all_global_ann, self._all_seq_ann = model.annotationTypes() self._all_structureless_seq_ann = [ ann for ann in self._all_seq_ann if not ann.requires_structure ] def _initializeBookkeeping(self): """ Clear and re-populate all of the bookkeeping data needed for this proxy. """ # not using _clearMouseOverIndex because it emits signals self._mouse_over_index = None self._seq_info = [] self._group_by_ann_info = [] source_model = self.sourceModel() if source_model is None: self._column_count = 0 return self._column_count = source_model.columnCount() for row in range(source_model.rowCount()): index = source_model.index(row, 0) has_structure = source_model.data(index, CustomRole.HasStructure) self._seq_info.append(SequenceInfo(has_structure)) aln = self.getAlignment() has_sets = aln is not None and aln.hasAlnSets() self._setAlnSetShown(has_sets) if self._options_model_cache.group_by is GroupBy.Sequence: self._initializeGroupBySeqData() else: self._initializeGroupByAnnData() def _initializeGroupBySeqData(self): """ Populate the bookkeeping data needed when we're grouping rows by sequence. """ for seq_num, cur_seq_info in enumerate(self._seq_info): self._updateSeqInfoAnnotations(seq_num, cur_seq_info) def _updateSeqInfoAnnotations(self, seq_num, seq_info): shown_anns = self._getAnnList(seq_num, seq_info.has_struc) seq_info.anns = shown_anns def _getAnnList(self, seq_num, has_struc): model = self.sourceModel() index = model.index(seq_num, 0) all_anns = self._all_seq_ann if has_struc else self._all_structureless_seq_ann new_anns = [] for ann in all_anns: if ann not in self._shown_row_types: continue role = RoleBase.SeqRowHeightScale + ann.value scale = model.data(index, role) if scale == 0: continue if ann.multi_value: indexes = model.data(index, RoleBase.SeqAnnotationIndexes + ann.value) if indexes is None: continue for idx in indexes: new_anns.append((ann, idx)) else: # TODO MSV-3293: allow hiding annotation rows from specific # sequences ann_idx = 0 new_anns.append((ann, ann_idx)) return new_anns def _getSeqAnnotationLists(self): """ Generate lists of currently shown sequence annotations for structured and structureless sequences. :return: A tuple of: - A list of sequence annotations for sequence with an associated structure. - A list of sequence annotations for sequence without an associated structure. :rtype: tuple """ structured = [] structureless = [] for ann in self._all_seq_ann: if ann in self._shown_row_types: structured.append(ann) if not ann.requires_structure: structureless.append(ann) return structured, structureless def _initializeGroupByAnnData(self): """ Populate the bookkeeping data needed when we're grouping rows by annotation. """ self._group_by_ann_seq_nums = self._genSeqNums() for ann in self._all_seq_ann: if ann not in self._shown_row_types: continue ann_info = self._createGroupByAnnInfo(ann) self._group_by_ann_info.append(ann_info) def _createGroupByAnnInfo(self, ann): """ Create and populate an object to track data for a single annotation. """ if ann.requires_structure: seq_nums = self._group_by_ann_seq_nums.structured_only else: seq_nums = self._group_by_ann_seq_nums.all if ann.multi_value: model = self.sourceModel() role = RoleBase.SeqAnnotationIndexes + ann.value ann_indexes = [] for seq_num in seq_nums: index = model.index(seq_num, 0) indexes = model.data(index, role) ann_indexes.append(indexes) ann_indexes = tuple(ann_indexes) else: ann_indexes = None return GroupByAnnotationInfo(ann, seq_nums, ann_indexes) def _genSeqNums(self): """ Return lists of sequence numbers that should be shown for annotations that don't require a structure and annotations that do require a structure. :rtype: `SequenceNums` """ all_nums = list(range(len(self._seq_info))) structured_nums = [ i for i, cur_seq in enumerate(self._seq_info) if cur_seq.has_struc ] return SequenceNums(all_nums, structured_nums)
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation seq_count = len(self._seq_info) num_global_ann = len(self._shown_global_ann) if seq_count == 0: return 0 elif parent is None or not parent.isValid(): if self._options_model_cache.group_by is GroupBy.Sequence: return seq_count + num_global_ann else: # GroupBy.Type return num_global_ann + seq_count + len(self._group_by_ann_info) elif parent.internalId() != TOP_LEVEL: # parent is not a top-level row return 0 row_num = parent.row() num_global_ann = len(self._shown_global_ann) if row_num < num_global_ann: return 0 elif self._options_model_cache.group_by is GroupBy.Sequence: seq_num = row_num - num_global_ann num_ann = len(self._seq_info[seq_num].anns) last_seq = seq_num == len(self._seq_info) - 1 # We include a spacer row except: # - The last sequence doesn't have a spacer (since there's nothing # after it) unless we're in the process of adding or removing # sequences from the end of the alignment (in which case # self._last_seq_has_spacer will be True). # - Spacers are hidden when the user is picking pairwise alignment # constraints. If we're in the process of enabling or disabling # this mode, then self._show_spacers_before_row will be set as we # add or remove spacer rows one-by-one. if (num_ann and (not last_seq or self._last_seq_has_spacer) and (self._show_spacers or row_num < self._show_spacers_before_row)): num_ann += SPACER return num_ann else: # GroupBy.Type if row_num < num_global_ann + seq_count: return 0 ann_num = row_num - num_global_ann - seq_count return len(self._group_by_ann_info[ann_num])
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return self._column_count
def _mapIndexToSource(self, proxy_index): """ Determine what source index and annotation the specified index maps to :param proxy_index: The index to map. Must be a valid index. :type proxy_index: `QtCore.QModelIndex` :return: A tuple of: - The source index that `proxy_index` maps to. Note that this will be an index in the first row for global annotation rows and an invalid index for spacer rows. - If the `proxy_index` row represents an annotation, returns an `AnnotationType` enum representing the annotation type. If the `proxy_index` row represents a sequence or a spacer, returns a `RowType` enum representing the row type. - If the `proxy_index` row represents an annotation, returns an annotation enum. - If the `proxy_index` row represents a multi-value annotation, returns an int representing the annotation index. :rtype: tuple """ source_row, ann_type, ann, ann_index = self._mapRowToSource( proxy_index.row(), proxy_index.internalId()) if source_row is None: source_index = QtCore.QModelIndex() else: source_index = self.sourceModel().index(source_row, proxy_index.column()) return source_index, ann_type, ann, ann_index def _mapRowToSource(self, proxy_row, internal_id): """ Determine what source row and annotation the specified row maps to. :param proxy_row: The row to map. :type proxy_index: int :param internal_id: The parent row (or `TOP_LEVEL` for top-level rows) of the row to map. :type internal_id: int :return: A tuple of: - The source row that `proxy_index` maps to. Note that this value will be 0 for global annotation rows and will be None for spacer rows. - If the `proxy_index` row represents an annotation, returns an `AnnotationType` enum representing the annotation type. If the `proxy_index` row represents a sequence or a spacer, returns a `RowType` enum representing the row type. - If the `proxy_index` row represents an annotation, returns an annotation enum. - If the `proxy_index` row represents a multi-value annotation, returns an int representing the annotation index. :rtype: tuple """ num_global_ann = len(self._shown_global_ann) ann_index = None if internal_id == TOP_LEVEL and proxy_row < num_global_ann: source_row = 0 ann_type = AnnotationType.Global ann = self._shown_global_ann[proxy_row] elif self._options_model_cache.group_by is GroupBy.Sequence: if internal_id == TOP_LEVEL: ann_type = RowType.Sequence ann = None source_row = proxy_row - num_global_ann else: source_row = internal_id - num_global_ann seq_anns = self._seq_info[source_row].anns if proxy_row == len(seq_anns): ann_type = RowType.Spacer ann = None else: ann_type = AnnotationType.Sequence ann, ann_index = seq_anns[proxy_row] else: # GroupBy.Type seq_count = len(self._seq_info) if internal_id == TOP_LEVEL: if proxy_row < num_global_ann + seq_count: ann_type = RowType.Sequence ann = None source_row = proxy_row - num_global_ann else: ann_type = RowType.Spacer group_num = proxy_row - num_global_ann - seq_count ann = self._group_by_ann_info[group_num].ann source_row = None else: ann_type = AnnotationType.Sequence group_num = internal_id - num_global_ann - seq_count ann_info = self._group_by_ann_info[group_num] ann = ann_info.ann source_row, ann_index = ann_info.getSourceRowInfo(proxy_row) return source_row, ann_type, ann, ann_index
[docs] def mapFromSource(self, source_index): # See Qt documentation for method documentation if not source_index.isValid(): return QtCore.QModelIndex() col = source_index.column() num_global_ann = len(self._shown_global_ann) proxy_row = source_index.row() + num_global_ann return self.index(proxy_row, col)
[docs] def mapToSource(self, proxy_index): # See Qt documentation for method documentation if not proxy_index.isValid(): return QtCore.QModelIndex() source_index, *_ = self._mapIndexToSource(proxy_index) return source_index
[docs] def setData(self, proxy_index, value, role=Qt.EditRole): # See Qt documentation for method documentation source_index = self.mapToSource(proxy_index) if source_index.isValid(): return self.sourceModel().setData(source_index, value, role) else: return False
[docs] def data(self, proxy_index, role=Qt.DisplayRole, multiple_roles=None): # See table_speed_up documentation for method documentation if not proxy_index.isValid(): return {} if role == CustomRole.MultipleRoles else None source_index, ann_type, ann, ann_index = self._mapIndexToSource( proxy_index) source_model = self.sourceModel() if role in self.ROLE_MAPPINGS: mapped_role = self._mappedRole(ann_type, ann, ann_index, *self.ROLE_MAPPINGS[role]) return source_model.data(source_index, mapped_role) elif (ann_type is AnnotationType.Global and role in self.NO_GLOBAL_DATA_ROLES): return None elif role not in self._data_methods: return source_model.data(source_index, role) # any change to these argument lists needs to also be made in rowData # and _multipleRolesData if role == CustomRole.MultipleRoles: data_args = (ann_type, ann, ann_index, source_index, source_model, multiple_roles) else: # add None as a place holder for the data dictionary that # _multipleRolesData would pass to data methods data_args = (ann_type, ann, ann_index, source_index, source_model, None, role) return self._callDataMethod(role, data_args)
[docs] def rowData(self, proxy_row, cols, internal_id, roles): """ Fetch data for multiple roles for multiple indices in the same row. Note that this method does minimal sanity checking of its input for performance reasons, as it is called during painting. The arguments are assumed to refer to valid indices. Use `data` instead if more sanity checking is required. :param proxy_row: The row number to fetch data for. :type proxy_row: int :param cols: A list of columns to fetch data for. :type cols: list(int) :param internal_id: The parent row (or `TOP_LEVEL` for top-level rows) of the row to fetch data for. :type internal_id: int :param roles: A list of roles to fetch data for. :type roles: list(int) :return: {role: data} dictionaries for each requested column. Note that the keys of these dictionaries may not match `roles`. Data for additional roles may be included (e.g. if that data was required to calculate data for a requested role). Data for requested roles may not be included if those roles are not applicable to the specified row (e.g. spacer rows may not provide data beyond row type and row title). :rtype: list(dict(int, object)) """ source_row, ann_type, ann, ann_index = self._mapRowToSource( proxy_row, internal_id) (roles, mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col) = \ self._mapRolesToSource(roles, ann_type, ann, ann_index) if (not roles or source_row is None or (ann_type is RowType.Spacer and CustomRole.NextRowHidden not in roles)): # Spacer rows don't need any data, so there's nothing to fetch from # the source model. The only exception is when we're deciding # whether to draw a hidden sequence marker after the spacer row at # the end of a sequence's annotations in group-by-sequence mode. row_data = [{} for _ in cols] else: row_data = self._source_model.rowData(source_row, cols, roles) roles.discard(Qt.DisplayRole) roles.discard(CustomRole.Residue) for cur_data in row_data: # We use the row number for the source_index argument and None for # the source_model argument. Note that any change to this argument # list needs to also be made in data and _multipleRolesData. self._fetchMultipleRoles(cur_data, roles, ann_type, ann, ann_index, source_row, None, cur_data) self._transformDataFromSource(row_data, mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col, ann_index) return row_data
@table_helper.data_method(CustomRole.MultipleRoles) def _multipleRolesData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles): # See table_speed_up documentation for method documentation. # Note that the last argument here is the list of roles to fetch, not a # dictionary of source data (as it would be in other methods decorated # with @table_helper.data_method). # Figure out for which roles we need to fetch data from the source model (multiple_roles, mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col) = \ self._mapRolesToSource(multiple_roles, ann_type, ann, ann_index) # Fetch data from the source model if multiple_roles: if source_index.isValid(): data = source_model.data(source_index, CustomRole.MultipleRoles, multiple_roles) else: data = {} multiple_roles.discard(Qt.DisplayRole) multiple_roles.discard(CustomRole.Residue) # We use the row number for the source_index argument and None for # the source_model argument. Note that any change to this argument # list needs to also be made in data and rowData. self._fetchMultipleRoles(data, multiple_roles, ann_type, ann, ann_index, source_index.row(), None, data) else: data = {} self._transformDataFromSource([data], mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col, ann_index) return data def _mapRolesToSource(self, multiple_roles, ann_type, ann, ann_index): """ Given a set of roles to fetch data for, figure out what data we need to request from `SequenceAlignmentModel` and how that data should be transformed before it's sent to the view. :param multiple_roles: A set of roles to fetch data for :type multiple_roles: set(int) :param ann_type: If the row represents an annotation, an `AnnotationType` enum representing the annotation type. If the row represents a sequence or a spacer, a `RowType` enum representing the row type. :type ann_type: `AnnotationType` or `RowType` :param ann: If the row represents an annotation, an annotation enum. Ignored otherwise. :type ann: `annotation._AnnotationEnum` :return: A tuple of - The roles to fetch data from `SequenceAlignmentModel` (set) - A dictionary of {view role: `SequenceAlignmentModel` role} (dict) - Whether `CustomRole.RowHeightScale` data was requested (bool) - The `SequenceAlignmentModel` role that was used to fetch `CustomRole.RowHeightScale` data (int or NoneType) - Whether `CustomRole.Residue` data was requested and this is a consensus sequence row (bool) - Whether `CustomRole.ChainCol` data was requested (bool) - Whether `CustomRole.ChainCol` data is being fetched from `SequenceAlignmentModel` using the `SeqInfo.Chain` role (bool) Note that these values are intended as input to `_transformDataFromSource`. :rtype: tuple """ multiple_roles = set(multiple_roles) mapped_roles = {} for orig_role, args in self.ROLE_MAPPINGS.items(): if orig_role not in multiple_roles: continue new_role = self._mappedRole(ann_type, ann, ann_index, *args) mapped_roles[orig_role] = new_role multiple_roles.discard(orig_role) if new_role is not None: multiple_roles.add(new_role) if ann_type is AnnotationType.Global: multiple_roles -= self.NO_GLOBAL_DATA_ROLES row_height_scale_requested = CustomRole.RowHeightScale in multiple_roles row_height_scale_role = None if row_height_scale_requested: multiple_roles.remove(CustomRole.RowHeightScale) if ann in self.ROW_HEIGHT_SCALE_ANNS: row_height_scale_role = RoleBase.SeqRowHeightScale + ann.value multiple_roles.add(row_height_scale_role) res_for_consensus = (CustomRole.Residue in multiple_roles and ann is ALN_ANNO_TYPES.consensus_seq) if res_for_consensus: multiple_roles.remove(CustomRole.Residue) multiple_roles.add(CustomRole.ConsensusSeq) if CustomRole.RowTitle in multiple_roles: if (ann_type is RowType.Sequence or (self._options_model_cache.group_by is GroupBy.Type and ann_type in AnnotationType)): multiple_roles.add(SeqInfo.Name) multiple_roles.add(SeqInfo.Title) if ann is SEQ_ANNO_TYPES.pfam: multiple_roles.add(CustomRole.PfamName) elif ann_index is not None: if ann is SEQ_ANNO_TYPES.domains: role = RoleBase.DomainName + ann_index multiple_roles.add(role) elif ann in (SEQ_ANNO_TYPES.binding_sites, SEQ_ANNO_TYPES.kinase_conservation): role = RoleBase.BindingSiteName + ann_index multiple_roles.add(role) chain_col_requested = CustomRole.ChainCol in multiple_roles provide_chain_col = None if chain_col_requested: multiple_roles.remove(CustomRole.ChainCol) provide_chain_col = ( ann_type is RowType.Sequence or (self._options_model_cache.group_by is GroupBy.Type and ann_type is AnnotationType.Sequence)) if provide_chain_col: multiple_roles.add(SeqInfo.Chain) if (CustomRole.NextRowHidden in multiple_roles and self._options_model_cache.group_by is GroupBy.Sequence and ann_type is not AnnotationType.Global): multiple_roles.add(CustomRole.SeqExpanded) if CustomRole.AnnotationSelected in multiple_roles: multiple_roles.add(CustomRole.Seq) if CustomRole.BindingSiteConstraint in multiple_roles: if ann is SEQ_ANNO_TYPES.binding_sites and ann_index is not None: role = RoleBase.BindingSiteName + ann_index multiple_roles.add(role) multiple_roles.add(CustomRole.ReferenceResidue) return (multiple_roles, mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col) def _mappedRole(self, ann_type, ann, ann_index, seq_role, seq_ann_base, global_ann_base): """ Map a Qt role from view roles to `SequenceAlignmentModel` roles. Because `SequenceAlignmentModel` doesn't have separate rows for annotations, it uses different role numbers to differentiate between sequences, sequence annotations, and global annotations. :param ann_type: If the row represents an annotation, an `AnnotationType` enum representing the annotation type. If the row represents a sequence or a spacer, a `RowType` enum representing the row type. :type ann_type: `AnnotationType` or `RowType` :param ann: If the row represents an annotation, an annotation enum. Ignored otherwise. :type ann: `annotation._AnnotationEnum` :param seq_role: The role to return if the row represents a sequence. :type seq_role: int :param seq_ann_base: The role base to use if the row represents a sequence annotation. The returned role will be this value plus the integer value of the annotation. :type seq_ann_base: RoleBase :param global_ann_base: The role base to use if the row represents a global annotation. The returned role will be this value plus the integer value of the annotation. :type global_ann_base: RoleBase :return: The appropriate role. :rtype: int """ if ann_type is RowType.Sequence: return seq_role elif ann_type is AnnotationType.Sequence: multi_value = ann_index is not None and ann.multi_value if multi_value and seq_ann_base == RoleBase.SeqBackground: seq_ann_base = self.MULTI_ROW_ANN_BACKGROUND_ROLE_BASE[ann] offset = ann_index elif multi_value and seq_ann_base == RoleBase.SeqToolTip: seq_ann_base = self.MULTI_ROW_ANN_TOOLTIP_ROLE_BASE[ann] offset = ann_index else: offset = ann.value return seq_ann_base + offset elif ann_type is AnnotationType.Global: return global_ann_base + ann.value else: # spacer rows return None def _transformDataFromSource(self, row_data, mapped_roles, row_height_scale_requested, row_height_scale_role, res_for_consensus, chain_col_requested, provide_chain_col, ann_index): """ Transform the data we received from `SequenceAlignmentModel` into data that can be interpreted by the view. This method transforms data for multiple cells at once. :param row_data: A list of {role: data} dictionaries to be transformed. These dictionaries will be transformed in place. :type row_data: list(dict(int, object)) :param mapped_roles: A dictionary of {view role: `SequenceAlignmentModel` role}. :type mapped_roles: dict(int, int) :param row_height_scale_requested: Whether `CustomRole.RowHeightScale` data was requested. :type row_height_scale_requested: bool :param row_height_scale_role: The `SequenceAlignmentModel` role that was used to fetch `CustomRole.RowHeightScale` data. Should be None if `CustomRole.RowHeightScale` was not requested. :type row_height_scale_role: int or NoneType :param res_for_consensus: Whether `CustomRole.Residue` data was requested and this is a consensus sequence row. :type res_for_consensus: bool :param chain_col_requested: Whether `CustomRole.ChainCol` data was requested. :type chain_col_requested: bool :param provide_chain_col: Whether `CustomRole.ChainCol` data was fetched from `SequenceAlignmentModel` using the `SeqInfo.Chain` role :type provide_chain_col: bool """ for cur_data in row_data: for orig_role, new_role in mapped_roles.items(): if new_role is None: cur_data[orig_role] = None elif new_role in cur_data: cur_data[orig_role] = cur_data[new_role] if row_height_scale_requested: if row_height_scale_role is not None: cur_data[CustomRole.RowHeightScale] = cur_data.get( row_height_scale_role, 1) else: cur_data[CustomRole.RowHeightScale] = 1 if res_for_consensus: # For the consensus sequence row, return Residue data if all # sequences have the same residue seq_data = cur_data[CustomRole.ConsensusSeq] res = seq_data[0] if len(seq_data) == 1 else None cur_data[CustomRole.Residue] = res if chain_col_requested: if provide_chain_col: cur_data[CustomRole.ChainCol] = cur_data.get(SeqInfo.Chain) else: cur_data[CustomRole.ChainCol] = None @table_helper.data_method(CustomRole.RowType) def _rowTypeData(self, ann_type, ann): """ Return the type of data contained in the specified row. The return value will be either a `RowType` enum or an annotation enum. """ if isinstance(ann_type, RowType): return ann_type elif ann_type is AnnotationType.Global: return ann elif ann_type is AnnotationType.Sequence: return ann @table_helper.data_method(CustomRole.MultiRowAnnIndex) def _annIndexData(self, ann_type, ann, ann_index): return ann_index @table_helper.data_method(CustomRole.DataRange) def _dataRangeData(self, ann_type, ann, ann_index, source_index, source_model): """ Return the range of values contained in the specified row. """ data = self._multipleRolesData(ann_type, ann, ann_index, source_index, source_model, {CustomRole.DataRange}) return data[CustomRole.DataRange] @table_helper.data_method(Qt.DisplayRole) def _displayData(self, ann_type, ann, ann_index, source_index, source_model): data = self._multipleRolesData(ann_type, ann, ann_index, source_index, source_model, {Qt.DisplayRole}) return data[Qt.DisplayRole] @table_helper.data_method(CustomRole.SeqRowEntryID) def _seqRowEntryIDData(self, ann_type, ann, ann_index, source_index, source_model): if ann_type is RowType.Sequence: return source_model.data(source_index, CustomRole.EntryID) @table_helper.data_method(CustomRole.Residue) def _residueData(self, ann_type, ann, ann_index, source_index, source_model): data = self._multipleRolesData(ann_type, ann, ann_index, source_index, source_model, {CustomRole.Residue}) return data.get(CustomRole.Residue) @table_helper.data_method(CustomRole.RowTitle) def _rowTitleData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): def get_seq_name(): if multiple_roles_data is not None: return multiple_roles_data.get(SeqInfo.Name) else: return source_model.data(source_index, SeqInfo.Name) def get_ann_title(): if ann is SEQ_ANNO_TYPES.pfam: title_role = CustomRole.PfamName elif ann is SEQ_ANNO_TYPES.domains: title_role = RoleBase.DomainName + ann_index elif ann in (SEQ_ANNO_TYPES.binding_sites, SEQ_ANNO_TYPES.kinase_conservation): title_role = RoleBase.BindingSiteName + ann_index else: return ann.title if multiple_roles_data is not None: return multiple_roles_data.get(title_role, "") else: return source_model.data(source_index, title_role) grouped_by_type = self._options_model_cache.group_by is GroupBy.Type if ann_type is AnnotationType.Global: return ann.title elif ann_type is RowType.Sequence: return get_seq_name() elif ann_type is RowType.Spacer: if grouped_by_type: return ann.title # spacer rows have no title in group by seq elif ann_type is AnnotationType.Sequence: ann_title = get_ann_title() if grouped_by_type: return f'{get_seq_name()} {ann_title}' else: return ann_title else: assert False @table_helper.data_method(CustomRole.ChainCol) def _chainColData(self, ann_type, ann, ann_index, source_index, source_model): data = self._multipleRolesData(source_model, ann_type, ann, source_index, {CustomRole.ChainCol}) return data[CustomRole.ChainCol] @table_helper.data_method(CustomRole.RowHeightScale) def _rowHeightScaleData(self, ann_type, ann, ann_index, source_index, source_model): data = self._multipleRolesData(ann_type, ann, ann_index, source_index, source_model, {CustomRole.RowHeightScale}) return data[CustomRole.RowHeightScale] @table_helper.data_method(CustomRole.SeqresOnly) def _seqresOnlyData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): if ann_type is AnnotationType.Global: return False elif multiple_roles_data is not None: return multiple_roles_data.get(CustomRole.SeqresOnly) else: return source_model.data(source_index, CustomRole.SeqresOnly) @table_helper.data_method(CustomRole.IncludeInDragImage) def _includeInDragImageData(self, ann_type, ann, ann_index, source_index, source_model): """ Whether the specified row should be drawn when we're drawing an image to represent the drag-and-dropped sequences. """ if not source_index.isValid() or source_index.row() == 0: # don't draw spacer rows or the reference sequence (since we can't # drag the reference sequence) return False elif self._options_model_cache.group_by is GroupBy.Sequence or ann_type is RowType.Sequence: # we include sequence annotation rows when in group-by-sequence # mode, but not when in group-by-type mode return source_index.data(CustomRole.SeqSelected) else: return False @table_helper.data_method(CustomRole.CanDropAbove) def _canDropAboveData(self, ann_type, ann, ann_index, source_index, source_model): """ Whether we can drop drag-and-dropped sequences above the specified row. """ return (ann_type is RowType.Sequence and source_index.row() != 0 and not source_model.data(source_index, CustomRole.SeqSelected)) @table_helper.data_method(CustomRole.CanDropBelow) def _canDropBelowData(self, ann_type, ann, ann_index, source_index, source_model): """ Whether we can drop drag-and-dropped sequences below the specified row. Note that the view won't allow drops below a row with expanded children regardless of what this method returns (since there's never a case where we want to do that. It would typically result in a drop between a sequence and its annotations.) """ seq_num = source_index.row() selected = source_model.data(source_index, CustomRole.SeqSelected) if selected: return False elif ann_type is RowType.Sequence: # The view won't allow drops below a row with expanded children, so # we don't need to worry about dropping into an annotation group # when in group-by-sequence mode return True elif ann_type is RowType.Spacer: # In group by sequence mode, spacers are the last child row of a # sequence row. Dropping after a spacer row therefore drops after # the sequence. return self._options_model_cache.group_by is GroupBy.Sequence elif ann_type is AnnotationType.Sequence: # In group by sequence mode, the last sequence row doesn't have a # spacer child, so we allow dropping after the last annoation return (self._options_model_cache.group_by is GroupBy.Sequence and seq_num == len(self._seq_info) - 1 and ann is self._seq_info[seq_num].anns[-1][0]) @table_helper.data_method(CustomRole.ResOrColSelected) def _resOrColSelectedData(self, ann_type, ann, ann_index, source_index, source_model): """ Whether residues or columns are being selected. Checks column selection when ruler row is index is passed, residue selection otherwise. """ if ann is ALN_ANNO_TYPES.indices: source_role = CustomRole.ColSelected else: source_role = CustomRole.ResSelected return source_model.data(source_index, source_role) @table_helper.data_method(CustomRole.PreviousRowHidden) def _prevRowHiddenData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): """ Was the previous row filtered out by the SequenceProxyFilterModel? Controls whether or not the AlignmentInfoView draws a hidden sequence marker between this row and the previous one. """ if multiple_roles_data is not None: prev_seq_hidden = multiple_roles_data.get( CustomRole.PreviousRowHidden) else: prev_seq_hidden = source_model.data(source_index, CustomRole.PreviousRowHidden) # Sequence rows get a marker in both group-by-sequence and group-by-type # modes. Sequence annotation rows get a marker when in group-by-type # mode. return prev_seq_hidden and ( ann_type is RowType.Sequence or (ann_type is AnnotationType.Sequence and self._options_model_cache.group_by is GroupBy.Type)) @table_helper.data_method(CustomRole.NextRowHidden) def _nextRowHiddenData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): """ Was the next row filtered out by the SequenceProxyFilterModel? Controls whether or not the AlignmentInfoView draws a hidden sequence marker between this row and the next one. """ if multiple_roles_data is not None: next_seq_hidden = multiple_roles_data.get(CustomRole.NextRowHidden) seq_expanded = multiple_roles_data.get(CustomRole.SeqExpanded) # source_index is a row number when this method gets called from # _multipleRolesData or rowData source_row = source_index else: next_seq_hidden = source_model.data(source_index, CustomRole.NextRowHidden) seq_expanded = source_model.data(source_index, CustomRole.SeqExpanded) source_row = source_index.row() if not next_seq_hidden: return False elif self._options_model_cache.group_by is GroupBy.Sequence: seq_anns = self._seq_info[source_row].anns if not seq_expanded or not seq_anns: # there are no sequence annotations for this sequence, so draw # the marker after the sequence row return ann_type is RowType.Sequence else: # If sequence annotations are present and expanded, then we want # to draw the marker after the last annotation row. For every # sequence but the last one, that's the spacer row. For the # last sequence, there's no spacer row so we need to check what # the last shown annotation is. return (ann_type is RowType.Spacer or (ann_type is AnnotationType.Sequence and source_row == len(self._seq_info) - 1 and ann is seq_anns[-1][0])) else: # GroupBy.Type return ann_type is not AnnotationType.Global @table_helper.data_method(CustomRole.AnnotationSelected) def _seqAnnSelectedData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): if ann_type not in AnnotationType: return False if ann_type is AnnotationType.Global: ann_info = GlobalAnnotationRowInfo(ann) else: if multiple_roles_data is not None: seq = multiple_roles_data.get(CustomRole.Seq) else: seq = source_model.data(source_index, CustomRole.Seq) if seq is None: return False ann_info = SequenceAnnotationRowInfo(seq, ann, ann_index) sel_model = self.getAlignment().ann_selection_model return sel_model.isSelected(ann_info) @table_helper.data_method(CustomRole.BindingSiteConstraint) def _bindingSiteConstraintData(self, ann_type, ann, ann_index, source_index, source_model, multiple_roles_data): if ann is not SEQ_ANNO_TYPES.binding_sites: return False ligand_name_role = RoleBase.BindingSiteName + ann_index if multiple_roles_data is not None: ligand_name = multiple_roles_data.get(ligand_name_role, "") ref_res = multiple_roles_data.get(CustomRole.ReferenceResidue) else: ligand_name = source_model.data(source_index, ligand_name_role) ref_res = source_model.data(source_index, CustomRole.ReferenceResidue) if ref_res is None or ref_res.is_gap: return False aln = self.getAlignment() return aln.isHMLigandConstraint(ref_res, ligand_name) def _clearMouseOverIndex(self): # Note: this emits fixedColumnDataChanged so must not be called while # the table is changing (e.g. during layoutChanged or between # beginRemoveRows and endRemoveRows) self.setMouseOverIndex(None)
[docs] def setMouseOverIndex(self, proxy_index): """ Set the given index as having the mouse over it :param proxy_index: The index the mouse is over, or None to clear the mouse over index :type proxy_index: QtCore.QModelIndex or None """ varname = '_mouse_over_index' self._setIndexOver(proxy_index, varname)
[docs] def setContextOverIndex(self, proxy_index): """ Set the given index as having the context menu over it :param proxy_index: The index the context menu is over :type proxy_index: QtCore.QModelIndex """ varname = '_context_over_index' self._setIndexOver(proxy_index, varname)
def _setIndexOver(self, proxy_index, varname): data_changed_rows = [] prev_value = getattr(self, varname) if prev_value is not None: data_changed_rows.append(prev_value[0]) if proxy_index is not None: new_value = self._getMouseOverInfoFromProxyIndex(proxy_index) data_changed_rows.append(new_value[0]) else: new_value = None setattr(self, varname, new_value) for changed_row in data_changed_rows: self.fixedColumnDataChanged.emit(CustomRole.MouseOver, changed_row) def _getMouseOverInfoFromProxyIndex(self, proxy_index): proxy_row = proxy_index.row() internal_id = proxy_index.internalId() if internal_id == TOP_LEVEL: top_level_row = proxy_row child_row = None else: top_level_row = internal_id child_row = proxy_row return (top_level_row, child_row)
[docs] def isMouseOverIndex(self, proxy_index): """ :return: Whether the given index represents a row that has the mouse over it """ if self._mouse_over_index is None and self._context_over_index is None: return False mouse_over_info = self._getMouseOverInfoFromProxyIndex(proxy_index) over_row = (mouse_over_info == self._mouse_over_index) or (mouse_over_info == self._context_over_index) return over_row
[docs] def setAnnSelectionState(self, proxy_index, selected): source_index, ann_type, ann, ann_idx = self._mapIndexToSource( proxy_index) if ann_type is AnnotationType.Global: ann_info = GlobalAnnotationRowInfo(ann) else: seq = source_index.data(CustomRole.Seq) ann_info = SequenceAnnotationRowInfo(seq, ann, ann_idx) sel_model = self.getAlignment().ann_selection_model sel_model.setSelectionState({ann_info}, selected)
[docs] def flags(self, index): """ See Qt documentation for additional method documentation Everything is selectable except for spacers :note: The return values from this function are cached on a per-row basis due to PerRowFlagCacheMixin. """ if not index.isValid(): return Qt.NoItemFlags source_index, ann_type, *_ = self._mapIndexToSource(index) flags = self.sourceModel().flags(source_index) return flags
@QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def _sourceRowsAboutToBeInserted(self, parent, source_start, source_end): """ Respond to the rowsAboutToBeInserted signal from the source model. See Qt documentation for rowsAboutToBeInserted for argument documentation. """ num_seqs = len(self._seq_info) seqs_to_insert = source_end - source_start + 1 num_global_ann = len(self._shown_global_ann) if num_seqs == 0: # We're inserting into an empty table proxy_end = num_global_ann + seqs_to_insert - ZERO_INDEXED if self._options_model_cache.group_by is GroupBy.Type: proxy_end += len(self._group_by_ann_info) self.beginInsertRows(QtCore.QModelIndex(), 0, proxy_end) else: if (self._options_model_cache.group_by is GroupBy.Sequence and source_start == num_seqs and self._seq_info[source_start - 1].anns): # In group by sequence mode, when adding rows at the end, # add spacer row to the previous last row if it has annos prev_last_source_row = source_start - 1 prev_last_proxy_row = num_global_ann + prev_last_source_row spacer_parent = self.index(prev_last_proxy_row, 0) last_row_seq = self._seq_info[prev_last_source_row] spacer_idx = len(last_row_seq.anns) self.beginInsertRows(spacer_parent, spacer_idx, spacer_idx) self._last_seq_has_spacer = True self.endInsertRows() proxy_start = num_global_ann + source_start proxy_end = num_global_ann + source_end self.beginInsertRows(QtCore.QModelIndex(), proxy_start, proxy_end) if self._options_model_cache.group_by is GroupBy.Type: # all seqs lists in self._group_by_ann_info are pointers to one # of the two lists in self._group_by_ann_seq_nums, so this will # update self._group_by_ann_info to account for the source model # numbering change, but it won't actually insert any of the new # sequences. The insertion is handled in # self._sourceRowsInserted. all_seq_nums = self._group_by_ann_seq_nums.all for i in range(source_start, num_seqs): all_seq_nums[i] += seqs_to_insert structured_only = self._group_by_ann_seq_nums.structured_only structured_only_start = bisect.bisect_left( structured_only, source_start) for i in range(structured_only_start, len(structured_only)): structured_only[i] += seqs_to_insert # Update self._seq_info with blank entries. The SequenceInfo objects # will be populated with data in _endRowInsert new_seq_info = [SequenceInfo() for i in range(seqs_to_insert)] self._seq_info[source_start:source_start] = new_seq_info @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def _sourceRowsInserted(self, parent, source_start, source_end): """ Respond to the rowsInserted signal from the source model. See Qt documentation for rowsInserted for argument documentation. """ # populate has_struc in self._seq_info source_model = self.sourceModel() num_structured = 0 for seq_num in range(source_start, source_end + 1): source_index = source_model.index(seq_num, 0) has_struc = source_model.data(source_index, CustomRole.HasStructure) self._seq_info[seq_num].has_struc = has_struc if has_struc: num_structured += 1 if self._options_model_cache.group_by is GroupBy.Sequence: # populate anns in self._seq_info structured, structureless = self._getSeqAnnotationLists() for seq_num in range(source_start, source_end + 1): cur_seq_info = self._seq_info[seq_num] self._updateSeqInfoAnnotations(seq_num, cur_seq_info) self._last_seq_has_spacer = False self.endInsertRows() else: # GroupBy.Type # finish inserting sequence rows self.endInsertRows() # insert child rows into the annotation groups self._sourceRowsInsertedGroupByType(source_start, source_end, num_structured) self._clearMouseOverIndex() # must be after endInsertRows # Update the global annotations if any are visible (which only happens # if we have at least one sequence loaded) num_global_ann = len(self._shown_global_ann) if num_global_ann and self._seq_info: topleft = self.index(0, 0) bottomright = self.index(num_global_ann - ZERO_INDEXED, self.columnCount() - ZERO_INDEXED) self.dataChanged.emit(topleft, bottomright) def _sourceRowsInsertedGroupByType(self, source_start, source_end, num_structured): """ In Group by Type mode, insert child rows into each annotation group. See Qt documentation for rowsInserted for additional argument documentation. :param num_structured: Number of structured sequences :type num_structured: int """ all_structureless = not num_structured seq_nums = self._genSeqNums() self._group_by_ann_seq_nums = seq_nums num_global_ann = len(self._shown_global_ann) if not all_structureless: struc_required_start = bisect.bisect_left(seq_nums.structured_only, source_start) struc_required_end = struc_required_start + num_structured - 1 # insert rows into each annotation group new_seq_count = len(self._seq_info) for ann_group_num, ann_info in enumerate(self._group_by_ann_info): ann = ann_info.ann if ann.requires_structure and all_structureless: # we don't need to insert any rows into this annotation, but # we still need to update the bookkeeping ann_info.seqs = seq_nums.structured_only continue group_row = num_global_ann + new_seq_count + ann_group_num group_index = self.index(group_row, 0) if ann.multi_value: if ann.requires_structure: my_seq_nums = seq_nums.structured_only my_start = struc_required_start else: my_seq_nums = seq_nums.all my_start = source_start previous_seq_num = my_seq_nums[my_start - 1] adj_start = ann_info.getProxyRowStart(previous_seq_num) adj_end = adj_start model = self.sourceModel() new_ann_indexes = [] for seq_num in range(source_start, source_end + 1): index = model.index(seq_num, 0) indexes = model.data( index, RoleBase.SeqAnnotationIndexes + ann.value) new_ann_indexes.append(indexes) adj_end += len(indexes) adding_new_rows = any(new_ann_indexes) if adding_new_rows: self.beginInsertRows(group_index, adj_start, adj_end - 1) tmp_ann_indexes = list(ann_info.ann_indexes) tmp_ann_indexes[my_start:my_start] = new_ann_indexes ann_info.ann_indexes = tuple(tmp_ann_indexes) ann_info.seqs = my_seq_nums if not adding_new_rows: # Skip endInsertRows because beginInsertRows wasn't called continue elif ann.requires_structure: self.beginInsertRows(group_index, struc_required_start, struc_required_end) ann_info.seqs = seq_nums.structured_only else: self.beginInsertRows(group_index, source_start, source_end) ann_info.seqs = seq_nums.all self.endInsertRows() @QtCore.pyqtSlot(QtCore.QModelIndex, int, int) def _sourceRowsAboutToBeRemoved(self, parent, source_start, source_end): """ Respond to the rowsAboutToBeRemoved signal from the source model. If in group by annotation mode, note that this method removes all annotation rows for the deleted sequences. Also note that self._group_by_ann_info and self._group_by_ann_seq_nums will be out of sync until `_sourceRowsRemoved` is run. See Qt documentation for rowsAboutToBeRemoved for argument documentation. """ num_seqs = len(self._seq_info) num_global_ann = len(self._shown_global_ann) removing_last_row = source_end == (num_seqs - ZERO_INDEXED) if source_start == 0 and removing_last_row: # We're clearing the table self.beginRemoveRows(QtCore.QModelIndex(), 0, self.rowCount() - 1) self._seq_info = [] if self._options_model_cache.group_by is GroupBy.Type: self._group_by_ann_seq_nums.all[:] = [] self._group_by_ann_seq_nums.structured_only[:] = [] return if self._options_model_cache.group_by is GroupBy.Type: self._sourceRowsAboutToBeRemovedGroupByType(source_start, source_end) proxy_start = num_global_ann + source_start proxy_end = num_global_ann + source_end self.beginRemoveRows(QtCore.QModelIndex(), proxy_start, proxy_end) del self._seq_info[source_start:source_end + 1] if ((parent is None or not parent.isValid()) and removing_last_row and self._seq_info[source_start - 1].anns and self._options_model_cache.group_by is GroupBy.Sequence): # The new last sequence's spacer row will be removed after finishing # the top level row removal in _sourceRowsRemoved self._last_seq_has_spacer = True def _sourceRowsAboutToBeRemovedGroupByType(self, source_start, source_end): """ In Group by Type mode, remove annotation rows for sequences about to be removed. Note that self._group_by_ann_info and self._group_by_ann_seq_nums will be out of sync until `_sourceRowsRemoved` is run. See Qt documentation for rowsAboutToBeRemoved for argument documentation. """ num_seqs = len(self._seq_info) num_global_ann = len(self._shown_global_ann) new_seq_nums = copy.deepcopy(self._group_by_ann_seq_nums) del new_seq_nums.all[source_start:source_end + 1] num_to_remove_with_struc = sum( 1 for seq_info in self._seq_info[source_start:source_end + 1] if seq_info.has_struc) if num_to_remove_with_struc > 0: struc_required_start = bisect.bisect_left( new_seq_nums.structured_only, source_start) struc_required_end = (struc_required_start + num_to_remove_with_struc - 1) del new_seq_nums.structured_only[ struc_required_start:struc_required_end + 1] # remove rows from each annotation group for ann_group_num, ann_info in enumerate(self._group_by_ann_info): ann = ann_info.ann if (ann.requires_structure and num_to_remove_with_struc == 0): # don't remove any rows from structure-required annotations # if all sequences being removed are structureless ann_info.seqs = new_seq_nums.structured_only continue group_row = num_global_ann + num_seqs + ann_group_num group_index = self.index(group_row, 0) if ann.multi_value: if ann.requires_structure: my_seq_nums = new_seq_nums.structured_only my_start = struc_required_start my_end = struc_required_end else: my_seq_nums = new_seq_nums.all my_start = source_start my_end = source_end previous_seq_num = my_seq_nums[my_start - 1] adj_start = ann_info.getProxyRowStart(previous_seq_num) adj_end = adj_start tmp_ann_indexes = list(ann_info.ann_indexes) for seq_num in reversed(range(my_start, my_end + 1)): indexes = tmp_ann_indexes.pop(seq_num) adj_end += len(indexes) ann_info.ann_indexes = tuple(tmp_ann_indexes) removing_rows = adj_end - adj_start > 0 if removing_rows: self.beginRemoveRows(group_index, adj_start, adj_end - 1) ann_info.seqs = my_seq_nums if not removing_rows: continue elif ann.requires_structure: self.beginRemoveRows(group_index, struc_required_start, struc_required_end) ann_info.seqs = new_seq_nums.structured_only else: self.beginRemoveRows(group_index, source_start, source_end) ann_info.seqs = new_seq_nums.all self.endRemoveRows() @QtCore.pyqtSlot() def _sourceRowsRemoved(self): """ Respond to the rowsRemoved signal from the source model. See Qt documentation for rowsRemoved for argument documentation. """ if (not self._seq_info or self._options_model_cache.group_by is GroupBy.Sequence): self.endRemoveRows() if (self._options_model_cache.group_by is GroupBy.Sequence and self._last_seq_has_spacer): # In group by sequence mode, remove spacer row from new last row new_last_proxy_row = self.rowCount() - 1 spacer_parent = self.index(new_last_proxy_row, 0) last_row_seq = self._seq_info[-1] spacer_idx = len(last_row_seq.anns) self.beginRemoveRows(spacer_parent, spacer_idx, spacer_idx) self._last_seq_has_spacer = False self.endRemoveRows() else: # GroupBy.Type # update self._group_by_ann_info and self._group_by_ann_seq_nums to # reflect the new sequence numbering. Note that we're just updating # the numbering here. The rows were already removed in # _beginRemoveRows. seq_nums = self._genSeqNums() self._group_by_ann_seq_nums = seq_nums for ann_info in self._group_by_ann_info: if ann_info.ann.requires_structure: ann_info.seqs = seq_nums.structured_only else: ann_info.seqs = seq_nums.all # finish removing rows from the structure group self.endRemoveRows() self._clearMouseOverIndex() # Must be after endRemoveRows # Update the global annotations num_global_ann = len(self._shown_global_ann) if num_global_ann and self.rowCount(): topleft = self.index(0, 0) bottomright = self.index(num_global_ann - ZERO_INDEXED, self.columnCount() - ZERO_INDEXED) self.dataChanged.emit(topleft, bottomright) @QtCore.pyqtSlot(QtCore.QModelIndex, QtCore.QModelIndex) def _modelDataChanged(self, source_topleft, source_bottomright): """ Respond to the dataChanged signal from the source model. See Qt documentation for dataChanged for argument documentation. """ if not source_topleft.isValid() and not source_bottomright.isValid(): self.dataChanged.emit(source_topleft, source_bottomright) return source_start_row = source_topleft.row() source_end_row = source_bottomright.row() col_left = source_topleft.column() col_right = source_bottomright.column() num_global_ann = len(self._shown_global_ann) # emit data changed for the sequence rows proxy_start_row = num_global_ann + source_start_row proxy_end_row = num_global_ann + source_end_row proxy_topleft = self.index(proxy_start_row, col_left) proxy_bottomright = self.index(proxy_end_row, col_right) self.dataChanged.emit(proxy_topleft, proxy_bottomright) if self._options_model_cache.group_by is GroupBy.Sequence: for seq_num in range(source_start_row, source_end_row + 1): anns = self._seq_info[seq_num].anns if not anns: continue parent = self.index(num_global_ann + seq_num, 0) top_left = self.index(0, col_left, parent) bottom_right = self.index( len(anns) - ZERO_INDEXED, col_right, parent) self.dataChanged.emit(top_left, bottom_right) elif self._options_model_cache.group_by is GroupBy.Type: num_seqs = len(self._seq_info) seq_nums = self._group_by_ann_seq_nums struc_required_start = bisect.bisect_left(seq_nums.structured_only, source_start_row) struc_required_end = bisect.bisect_right(seq_nums.structured_only, source_end_row, struc_required_start) - 1 # emit data changed for each annotation group for ann_group_num, ann_info in enumerate(self._group_by_ann_info): group_row = num_global_ann + num_seqs + ann_group_num group_index = self.index(group_row, 0) if not ann_info.ann.requires_structure: top_left = self.index(source_start_row, col_left, group_index) bottom_right = self.index(source_end_row, col_right, group_index) self.dataChanged.emit(top_left, bottom_right) elif struc_required_start <= struc_required_end: top_left = self.index(struc_required_start, col_left, group_index) bottom_right = self.index(struc_required_end, col_right, group_index) self.dataChanged.emit(top_left, bottom_right) if num_global_ann: global_topleft = self.index(0, col_left) global_bottomright = self.index(num_global_ann - ZERO_INDEXED, col_right) self.dataChanged.emit(global_topleft, global_bottomright) def _seqExpansionChanged(self, source_indices, expanded): # See SeqExpansionProxyMixin._seqExpansionChanged for method # documentation super()._seqExpansionChanged(source_indices, expanded) self._clearMouseOverIndex() def _updateGroupBy(self): """ Should the rows be grouped by sequence or by annotation type? """ group_by = self._options_model_cache.group_by if not isinstance(group_by, GroupBy): raise TypeError self.beginResetModel() try: self._initializeBookkeeping() finally: self.endResetModel() self.groupByChanged.emit(group_by)
[docs] def getGroupBy(self): """ Are the rows currently grouped by sequence or by annotation type? :return: The current setting :rtype: `GroupBy` """ return self._options_model_cache.group_by
@QtCore.pyqtSlot() def _alnSetChanged(self): has_sets = self.getAlignment().hasAlnSets() if has_sets != self._show_alignment_set: self._setAlnSetShown(has_sets) # always invalidate filtering so that we update whether alignment set # rows are shown or not for each sequence self._invalidateFilter() def _setAlnSetShown(self, show): self._show_alignment_set = show if show: self._shown_row_types.add(SEQ_ANNO_TYPES.alignment_set) else: self._shown_row_types.discard(SEQ_ANNO_TYPES.alignment_set) def _updateVisibleRowTypes(self): """ Update visible row types based on the options model. """ row_types = self._options_model.all_visible_annotations self._setShownRowTypes(row_types) def _setShownRowTypes(self, row_types): """ Allow only the specified annotations .. NOTE:: Calling code should set shown annotations via `schrodinger.gui_models.OptionsModel` (residue_propensity_annotations, sequence_annotations, and alignment_annotations) :param row_types: An iterable containing the annotations to allow :type row_types: iter """ self._shown_row_types.clear() self._shown_row_types.update(set(row_types)) if self._show_alignment_set: self._shown_row_types.add(SEQ_ANNO_TYPES.alignment_set) self._invalidateFilter()
[docs] def getShownRowTypes(self): """ Return a set of allowed annotation types :return: A set of annotation types. Note that the returned value is a copy of the attribute variable, so modifying it will not have any effect on this proxy. :rtype: set """ return set(self._shown_row_types)
def _setVisibilityForRowType(self, row_type, show=True): """ Toggle visibility for the specified annotation .. NOTE:: This method is provided as a convenience for tests. Calling code should set shown annotations via `schrodinger.gui_models.OptionsModel` (residue_propensity_annotations, sequence_annotations, and alignment_annotations) :param row_type: The annotation to adjust :type row_type: `enum.Enum` :param show: Whether the annotation should be shown or hidden :type show: bool """ if show: self._shown_row_types.add(row_type) else: self._shown_row_types.discard(row_type) self._invalidateFilter() def _setVisibilityForRowTypes(self, row_types, show=True): """ Toggle visibility for the specified annotations .. NOTE:: This method is provided as a convenience for tests. Calling code should set shown annotations via `schrodinger.gui_models.OptionsModel` (residue_propensity_annotations, sequence_annotations, and alignment_annotations) :param row_types: An iterable containing the annotations to adjust :type row_types: iter :param show: Whether the annotations should be shown or hidden :type show: bool """ if show: self._shown_row_types.update(row_types) else: self._shown_row_types.difference_update(row_types) self._invalidateFilter()
[docs] def getNumShownGlobalAnnotations(self): """ Return the number of shown global annotations """ return len(self._shown_global_ann)
@QtCore.pyqtSlot() def _invalidateFilter(self): """ Update which annotations are shown and hidden. Update `self._shown_row_types` before calling this method. """ if not self._seq_info: # If the table is empty, we don't need to worry about inserting or # removing rows self._shown_global_ann = [ ann for ann in self._all_global_ann if ann in self._shown_row_types ] if self._options_model_cache.group_by is GroupBy.Type: self._group_by_ann_info = [ GroupByAnnotationInfo(cur_ann, []) for cur_ann in self._all_seq_ann if cur_ann in self._shown_row_types ] return else: # update the global annotation rows new_anns = list(ann for ann in self._all_global_ann if ann in self._shown_row_types) self._updateAnnotationList(self._shown_global_ann, new_anns, QtCore.QModelIndex()) # update the sequence annotation rows if self._options_model_cache.group_by is GroupBy.Sequence: self._updateSeqAnnForGroupBySeq() else: self._updateSeqAnnForGroupByAnn() @QtCore.pyqtSlot() def _onSequenceStructureChanged(self): """ Update the sequence info to have the latest has structure. Then update which annotations are visible. """ self._updateHasStructure() self._invalidateFilter() def _updateHasStructure(self): """ Update the sequence information for having structure when sequence structure is changed. """ source_model = self.sourceModel() for row in range(source_model.rowCount()): index = source_model.index(row, 0) has_structure = source_model.data(index, CustomRole.HasStructure) self._seq_info[row].has_struc = has_structure def _updateAnnotationList(self, cur_shown_ann, to_show_ann, parent, account_for_spacer=False): """ Update the current list of shown annotations to match `to_show_ann`. Row insertion and removal signals will be emitted for all changes. :param cur_shown_ann: The list of shown annotations to update :type cur_shown_ann: list :param to_show_ann: The list of annotations that `cur_shown_ann` should be updated to match. Note that all annotations shared between `cur_shown_ann` and `to_show_ann` should be in the same order. :type to_show_ann: list :param parent: The parent index to emit with the row insertion and removal signals. :type parent: `QtCore.QModelIndex` :param account_for_spacer: Whether the `parent` group can have a spacer as the last row. If True, this spacer row will be removed when the last annotation is removed and added when adding an annotation to an empty group. :type account_for_spacer: bool """ # remove all annotations that were shown but are now hidden for i, cur_ann in reversed(list(enumerate(cur_shown_ann))): if cur_ann not in to_show_ann: if account_for_spacer and len(cur_shown_ann) == 1: spacer = 1 else: spacer = 0 self.beginRemoveRows(parent, i, i + spacer) cur_shown_ann.pop(i) self.endRemoveRows() # add all annotations that were hidden but are now shown i = 0 for cur_ann in to_show_ann: if cur_ann in cur_shown_ann: i += 1 else: if account_for_spacer and not cur_shown_ann: spacer = 1 else: spacer = 0 self.beginInsertRows(parent, i, i + spacer) cur_shown_ann.insert(i, cur_ann) self.endInsertRows() i += 1 def _updateSeqAnnForGroupBySeq(self): """ Update which sequence annotations are shown when rows are grouped by sequence. """ last_seq_num = len(self._seq_info) - 1 for seq_num, cur_seq in enumerate(self._seq_info): new_anns = self._getAnnList(seq_num, cur_seq.has_struc) row = len(self._shown_global_ann) + seq_num parent = self.index(row, 0) last_seq = seq_num == last_seq_num account_for_spacer = self._show_spacers and not last_seq self._updateAnnotationList(cur_seq.anns, new_anns, parent, account_for_spacer=account_for_spacer) def _updateSeqAnnForGroupByAnn(self): """ Update which sequence annotations are shown when rows are grouped by annotation. """ # remove all annotations that were shown but are now hidden num_seqs = len(self._seq_info) invalid_index = QtCore.QModelIndex() for i, cur_ann_info in reversed(list(enumerate( self._group_by_ann_info))): if cur_ann_info.ann not in self._shown_row_types: row_num = len(self._shown_global_ann) + num_seqs + i self.beginRemoveRows(invalid_index, row_num, row_num) self._group_by_ann_info.pop(i) self.endRemoveRows() # add all annotations that were hidden but are now shown i = 0 shown_ann = [ann_info.ann for ann_info in self._group_by_ann_info] for cur_ann in self._all_seq_ann: if cur_ann in shown_ann: i += 1 elif cur_ann in self._shown_row_types: row_num = len(self._shown_global_ann) + num_seqs + i self.beginInsertRows(invalid_index, row_num, row_num) ann_info = self._createGroupByAnnInfo(cur_ann) self._group_by_ann_info.insert(i, ann_info) self.endInsertRows() i += 1
[docs] @QtCore.pyqtSlot() def endInsertColumns(self): # See Qt documentation for method documentation self._column_count = self.sourceModel().columnCount() super().endInsertColumns()
[docs] @QtCore.pyqtSlot() def endRemoveColumns(self): # See Qt documentation for method documentation self._column_count = self.sourceModel().columnCount() super().endRemoveColumns()
[docs] def tableWidthChanged(self, width): """ Emit the `tableWidthChangedSignal` with the specified width. The `RowWrapProxyModel` should receive this signal and adjust the wrapping as necessary. :param width: The current number of columns in the table :type width: int """ self.tableWidthChangedSignal.emit(width)
[docs] def rowWrapEnabled(self): """ :return: Whether this model provides row-wrapped data. :type: bool .. warning:: You probably don't want to use this method. Whenever possible, you should pass information to the model and have it respond as appropriate (i.e. tell, don't ask). This method should only be used for view features that function differently depending on whether the model is row-wrapped or not (e.g. drag-and-drop auto-scrolling). """ return False
[docs] def getPickingMode(self): """ :return: The current picking mode .. warning:: This method should only be used for view features that function differently depending on the pick mode (e.g. mouse clicks). """ return self._options_model.pick_mode
[docs] def handlePick(self, index): """ Handle a pick event at the given proxy index. Has no effect if the pick mode is not HMBindingSite or HMProximity. """ pick_mode = self.getPickingMode() if pick_mode not in (PickMode.HMBindingSite, PickMode.HMProximity): return source_index, ann_type, ann, ann_idx = self._mapIndexToSource(index) if (pick_mode is PickMode.HMBindingSite and ann is SEQ_ANNO_TYPES.binding_sites): self.sourceModel().handleBindingSitePick(source_index, ann_idx) elif (pick_mode is PickMode.HMProximity and ann_type is RowType.Sequence): self.sourceModel().handleProximityPick(source_index)
def _updateShowSpacer(self): """ Respond to a change in OptionsModel.annotation_spacer_enabled by adding or removing spacer rows. Note that this setting only affects group-by-sequence mode. """ if self._show_spacers == self._options_model.annotation_spacer_enabled: # nothing has actually changed, so we don't need to do anything here return elif (self._options_model_cache.group_by is GroupBy.Type or not self._shown_row_types.intersection(self._all_seq_ann)): # this change isn't going to affect any visible spacers (either # because we're in group-by-type mode or because no sequence-level # annotations are shown), so we don't have to worry about emitting # signals for adding or removing spacer rows pass elif self._options_model.annotation_spacer_enabled: # adding spacer rows # We set _show_spacers to None since some sequences will have spacer # rows and some won't while we're in the process of adding spacers. # That way, rowCount will use _show_spacers_before_row instead. self._show_spacers = None self._show_spacers_before_row = 0 # we don't need to add a spacer row to the last sequence for seq_num, cur_seq in enumerate(self._seq_info[:-1]): if not cur_seq.anns: continue parent_row_num = len(self._shown_global_ann) + seq_num spacer_row_num = len(self._seq_info[seq_num].anns) seq_row_index = self.index(parent_row_num, 0) self.beginInsertRows(seq_row_index, spacer_row_num, spacer_row_num) self._show_spacers_before_row = parent_row_num + 1 self.endInsertRows() else: # removing spacer rows # We set _show_spacers to None since some sequences will have spacer # rows and some won't while we're in the process of removing # spacers. That way, rowCount will use _show_spacers_before_row # instead. self._show_spacers = None self._show_spacers_before_row = self.rowCount() # the last sequence doesn't have a spacer row for seq_num, cur_seq in reversed( list(enumerate(self._seq_info[:-1]))): if not cur_seq.anns: continue parent_row_num = len(self._shown_global_ann) + seq_num spacer_row_num = len(self._seq_info[seq_num].anns) seq_row_index = self.index(parent_row_num, 0) self.beginRemoveRows(seq_row_index, spacer_row_num, spacer_row_num) self._show_spacers_before_row = parent_row_num self.endRemoveRows() self._show_spacers_before_row = -1 self._show_spacers = self._options_model.annotation_spacer_enabled
[docs]class GroupByProxyMixin: groupByChanged = QtCore.pyqtSignal(object)
[docs] def setSourceModel(self, model): # See QAbstractItemView documentation for method documentation old_model = self.sourceModel() signals = {'groupByChanged': self.groupByChanged} if old_model is not None: table_helper.disconnect_signals(old_model, signals) super(GroupByProxyMixin, self).setSourceModel(model) table_helper.connect_signals(model, signals)
[docs] def getGroupBy(self): """ :return: The current `GroupBy` setting. :rtype: viewconstants.GroupBy """ return self.sourceModel().getGroupBy()
[docs]class PostAnnotationProxyMixin(GroupByProxyMixin, MouseOverPassthroughMixin, AnnotationSelectionPassthroughMixin, ProxyMixin): """ A mixin for all proxies that are used after the `AnnotationProxyModel`. """
[docs] def getShownRowTypes(self): """ See `AnnotationProxyModel.getShownRowTypes` for method documentation. """ return self.sourceModel().getShownRowTypes()
[docs] def getNumShownGlobalAnnotations(self): """ See `AnnotationProxyModel.getNumShownGlobalAnnotations` for method documentation """ return self.sourceModel().getNumShownGlobalAnnotations()
[docs] def rowWrapEnabled(self): """ See `AnnotationProxyModel.rowWrapEnabled` for method documentation """ return self.sourceModel().rowWrapEnabled()
[docs] def getPickingMode(self): """ See `AnnotationProxyModel.getPickingMode` for method documentation """ return self.sourceModel().getPickingMode()
[docs]class RowWrapInsertingRows(object): """ An object to store `RowWrapProxyModel` bookkeeping for the insertion of top-level rows. :ivar old_source_row_count: The number of rows in the source model before the insertion. :vartype old_source_row_count: int :ivar new_source_row_count: The number of rows in the source model after the insertion. :vartype new_source_row_count: int :ivar source_start: The row number of the first row being inserted. :vartype source_start: int :ivar source_start: The row number of the last row being inserted. :vartype source_start: int :ivar num_new_rows: The number of rows being inserted. :vartype num_new_rows: int :ivar wrap: The wrap of `RowWrapProxyModel` that rows were just inserted into - see `RowWrapProxyModel._sourceRowsAboutToBeInserted`. :vartype wrap: int :ivar update_row_num: The row number of `RowWrapProxyModel` that was just updated; i.e., that just had child rows inserted into it - see `RowWrapProxyModel._sourceRowsInserted`. :vartype update_row_num: int """
[docs] def __init__(self, old_source_row_count, new_source_row_count, source_start, source_end): self.old_source_row_count = old_source_row_count self.new_source_row_count = new_source_row_count self.source_start = source_start self.source_end = source_end self.num_new_rows = source_end - source_start + 1 self.wrap = -1 self.update_row_num = -1
[docs]class RowWrapRemovingRows(object): """ An object to store `RowWrapProxyModel` bookkeeping for the removal of top-level rows. :ivar old_source_row_count: The number of rows in the source model before the removal. :vartype old_source_row_count: int :ivar new_source_row_count: The number of rows in the source model after the removal. :vartype new_source_row_count: int :ivar source_start: The row number of the first row being removed. :vartype source_start: int :ivar source_start: The row number of the last row being removed. :vartype source_start: int :ivar num_rem_rows: The number of rows being removed. :type num_new_rows: int :ivar wrap: The wrap of `RowWrapProxyModel` that rows were just removed from - see `RowWrapProxyModel._sourceRowsAboutToBeRemoved`. :vartype wrap: int """
[docs] def __init__(self, old_source_row_count, new_source_row_count, source_start, source_end): self.old_source_row_count = old_source_row_count self.new_source_row_count = new_source_row_count self.source_start = source_start self.source_end = source_end self.num_rem_rows = source_end - source_start + 1 self.wrap = -1
[docs]class RowWrapInsertingChildRows(object): """ An object to store `RowWrapProxyModel` bookkeeping for the insertion of child rows. :ivar source_parent_row: The row number of the top-level row that the child rows are being inserted into. :vartype source_parent_row: int :ivar source_start: The row number of the first row being inserted. :vartype source_start: int :ivar source_start: The row number of the last row being inserted. :vartype source_start: int :ivar num_new_rows: The number of rows being inserted. :vartype num_new_rows: int :ivar wrap: The wrap of `RowWrapProxyModel` that rows were just inserted into - see `RowWrapProxyModel._sourceRowsAboutToBeInserted`. :vartype wrap: int """
[docs] def __init__(self, source_parent_row, source_start, source_end): self.source_parent_row = source_parent_row self.source_start = source_start self.source_end = source_end self.num_new_rows = source_end - source_start + 1 self.wrap = -1
[docs]class RowWrapRemovingChildRows(object): """ An object to store `RowWrapProxyModel` bookkeeping for the removal of child rows. :ivar source_parent_row: The row number of the top-level row that the child rows are being removed from. :vartype source_parent_row: int :ivar source_start: The row number of the first row being removed. :vartype source_start: int :ivar source_start: The row number of the last row being removed. :vartype source_start: int :ivar num_rem_rows: The number of rows being removed. :type num_new_rows: int :ivar wrap: The wrap of `RowWrapProxyModel` that rows were just removed from - see `RowWrapProxyModel._sourceRowsAboutToBeRemoved`. :vartype wrap: int """
[docs] def __init__(self, source_parent_row, source_start, source_end): self.source_parent_row = source_parent_row self.source_start = source_start self.source_end = source_end self.num_rem_rows = source_end - source_start + 1 self.wrap = -1
[docs]class RowWrapProxyModel(PerRowFlagCacheProxyMixin, PostAnnotationProxyMixin, NestedProxy): """ A proxy model that wraps rows to a specified column count. A blank spacer row will be inserted between each wrap. (Note that there's no spacer row before the first wrap or after the last wrap.) :ivar _width: The current width of the table view, measured in number of columns. The is the width that this proxy will wrap to. :type _width: int :ivar _source_column_count: The current number of columns in this proxy's source model. :vartype _source_column_count: int :ivar _column_count: The current number of columns in this proxy. :vartype _column_count: int :ivar _wrap_count: The number of times the rows are wrapped to fit within `_width` columns. :vartype _wrap_count: int :ivar _top_level_row_count: The current number of top-level row in this proxy (i.e. excluding children rows). :vartype _top_level_row_count: int :ivar _source_child_row_counts: The number of child rows for each top-level row of the source model. Note that the length of this list is always equal to the number of top-level rows in the source model. :vartype _source_child_row_counts: list :ivar _inserting_rows: If we're in the process of inserting top-level rows into the model, this will be a `RowWrapInsertingRows` object describing the insertion. Will be None at all other times. :vartype _inserting_rows: `RowWrapInsertingRows` or NoneType :ivar _updating_inserted_rows: If we're in the process of updating top-level rows that were just inserted into the model, this will be a `RowWrapInsertingRows` object describing the updating. Will be None at all other times. :vartype _updating_inserted_rows: `RowWrapInsertingRows` or NoneType :ivar _inserting_child_rows: If we're in the process of inserting child rows into the model, this will be a `RowWrapInsertingChildRows` object describing the insertion. Will be None at all other times. :vartype _inserting_child_rows: `RowWrapInsertingChildRows` or NoneType :ivar _removing_rows: If we're in the process of removing top-level rows from the model, this will be a `RowWrapRemovingRows` object describing the removal. Will be None at all other times. :vartype _removing_rows: `RowWrapRemovingRows` or NoneType :ivar _removing_child_rows: If we're in the process of removing child rows from the model, this will be a `RowWrapRemovingChildRows` object describing the removal. Will be None at all other times. :vartype _removing_child_rows: `RowWrapRemovingChildRows` or NoneType """
[docs] def __init__(self, parent=None): # See Qt documentation for argument documentation super().__init__(parent) self._width = 100 self._source_column_count = 0 self._column_count = 0 self._wrap_count = 1 self._top_level_row_count = 0 self._source_child_row_counts = [] self._inserting_rows = None self._updating_inserted_rows = None self._inserting_child_rows = None self._removing_rows = None self._removing_child_rows = None
[docs] def tableWidthChanged(self, width): """ Wrap the table to the specified number of columns. :param width: The number of columns to wrap to. :type width: int """ if width == 0: # this happens when the view is in the process of being shown return self.beginResetModel() self._width = width self._wrap_count = self._wrapCount(self._source_column_count) source_row_count = len(self._source_child_row_counts) self._top_level_row_count = self._wrap_count * (source_row_count + SPACER) - SPACER self._column_count = min(self._source_column_count, self._width) self._flag_cache.clear() self.endResetModel()
[docs] @table_helper.model_reset_method def setSourceModel(self, model): # See Qt documentation for argument documentation super(RowWrapProxyModel, self).setSourceModel(model) model.modelAboutToBeReset.connect(self.beginResetModel) model.modelReset.connect(self._sourceModelReset) model.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged) model.layoutChanged.connect(self._sourceLayoutChanged) model.rowsAboutToBeInserted.connect(self._sourceRowsAboutToBeInserted) model.rowsInserted.connect(self._sourceRowsInserted) model.columnsAboutToBeInserted.connect( self._sourceColumnsAboutToBeInserted) model.columnsAboutToBeRemoved.connect( self._sourceColumnsAboutToBeRemoved) model.columnsInserted.connect(self._sourceColumnsInsertedOrRemoved) model.columnsRemoved.connect(self._sourceColumnsInsertedOrRemoved) model.dataChanged.connect(self._sourceDataChanged) model.rowsAboutToBeRemoved.connect(self._sourceRowsAboutToBeRemoved) model.rowsRemoved.connect(self._sourceRowsRemoved) model.tableWidthChangedSignal.connect(self.tableWidthChanged) self._resetColumnCount() self._resetRowCounts()
def _resetColumnCount(self): """ Reset all column-related bookkeeping data using the current source model column count. """ source_model = self.sourceModel() source_col_count = source_model.columnCount() self._source_column_count = source_col_count self._column_count = min(source_col_count, self._width) self._wrap_count = self._wrapCount(source_col_count) def _resetRowCounts(self): """ Reset all row-related bookkeeping data using the current source model row counts. :note: If resetting both column- and row-related bookkeeping data, `_resetColumnCount` must be run before `_resetRowCounts`, as this function uses `_wrap_count`, which gets reset in {_resetColumnCount}. """ source_model = self.sourceModel() new_row_counts = [] source_row_count = source_model.rowCount() self._top_level_row_count = self._wrap_count * (source_row_count + SPACER) - SPACER for cur_row in range(source_row_count): index = source_model.index(cur_row, 0) row_count = source_model.rowCount(index) new_row_counts.append(row_count) self._source_child_row_counts = new_row_counts def _wrapCount(self, source_cols): """ Calculate the number of times that a table would need to be wrapped to fit into the current width. :param source_cols: The number of columns in the source table to use for the wrapping calculation. :type source_cols: int :return: The number of wrappings :rtype: int """ num_wraps = math.ceil(source_cols / float(self._width)) num_wraps = int(num_wraps) return max(num_wraps, 1)
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return self._column_count
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation if parent is None or not parent.isValid(): return self._top_level_row_count elif parent.internalId() != TOP_LEVEL: # parent is not a top-level row return 0 else: source_row, wrap_num, is_spacer = \ self._mapTopLevelRowToSource(parent.row()) if is_spacer or source_row is None: # spacer row between wraps or can't map source_row (which means # we're in the process of inserting the source row) return 0 proxy_row = parent.row() row_count = self._source_child_row_counts[source_row] ins = self._updating_inserted_rows ins_child = self._inserting_child_rows rem_child = self._removing_child_rows if (ins is not None and ins.source_start <= source_row <= ins.source_end and proxy_row > ins.update_row_num): # We're in the process of inserting top-level rows. Dummy # rows (with no children) were inserted into each wrap and # we haven't yet emitted signals to update the dummy rows in # this wrap, so we shouldn't report any children yet. row_count = 0 elif (ins_child is not None and source_row == ins_child.source_parent_row and wrap_num > ins_child.wrap): # We're in the process of inserting child rows. # self._source_child_row_counts has already been updated, # but we haven't emitted signals for inserting rows into # this wrap yet, so we shouldn't count the new rows. row_count -= ins_child.num_new_rows elif (rem_child is not None and source_row == rem_child.source_parent_row and wrap_num > rem_child.wrap): # We're in the process of removing child rows. # self._source_child_row_counts has already been updated, # but we haven't emitted signals for removing rows in this # wrap yet, so we should still count the removed rows. row_count += rem_child.num_rem_rows return row_count
[docs] def mapFromSource(self, source_index): # See Qt documentation for argument documentation if not source_index.isValid(): return QtCore.QModelIndex() source_model = self.sourceModel() source_parent = source_model.parent(source_index) if not source_parent.isValid(): proxy_index, is_valid, wrap_num = self._mapTopLevelIndexFromSource( source_index) return proxy_index else: source_col = source_index.column() proxy_parent, is_valid, wrap_num = self._mapTopLevelIndexFromSource( source_parent, child_col=source_col) if not is_valid: # the source index is a child of a row that in the process of # being removed return proxy_parent proxy_row = source_index.row() proxy_col = source_col % self._width ins = self._inserting_child_rows rem = self._removing_child_rows if (ins is not None and source_parent.row() == ins.source_parent_row and proxy_row >= ins.source_start and wrap_num <= ins.wrap): proxy_row += ins.num_new_rows elif (rem is not None and source_parent.row() == rem.source_parent_row and wrap_num <= rem.wrap): if proxy_row > rem.source_end: proxy_row -= rem.num_rem_rows elif proxy_row >= rem.source_start: return QtCore.QModelIndex() return self.index(proxy_row, proxy_col, proxy_parent)
def _mapTopLevelIndexFromSource(self, source_index, child_col=None): """ Map a top-level index from the source model. :param source_index: The source index to map. :type source_index: `QtCore.QModelIndex` :param child_col: If `source_index` is the parent of a child index that's being mapped, the column of the child index, needed so that this method can return a proxy index from the appropriate wrap. Should be None otherwise. :type child_col: int or NoneType :return: A tuple of: - The equivalent proxy index (`QtCore.QModelIndex`) - Whether the proxy index is valid (bool) - The wrap that the proxy index is in. Will be None if the proxy index is invalid. (int or NoneType) :rtype: tuple """ source_row = source_index.row() if child_col is None: source_col = source_index.column() proxy_col = source_col % self._width else: source_col = child_col proxy_col = 0 wrap_num = source_col // self._width ins = self._inserting_rows rem = self._removing_rows if ins is not None: if wrap_num < (ins.wrap + ZERO_INDEXED): proxy_row = (wrap_num * (ins.new_source_row_count + SPACER) + source_row) if source_row >= ins.source_start: proxy_row += ins.num_new_rows else: old_wraps = wrap_num - (ins.wrap + ZERO_INDEXED) proxy_row = ((ins.wrap + ZERO_INDEXED) * (ins.new_source_row_count + SPACER) + old_wraps * (ins.old_source_row_count + SPACER) + source_row) elif rem is not None: if wrap_num < (rem.wrap + ZERO_INDEXED): proxy_row = (wrap_num * (rem.new_source_row_count + SPACER) + source_row) if source_row > rem.source_end: proxy_row -= rem.num_rem_rows elif source_row >= rem.source_start: return QtCore.QModelIndex(), False, None else: old_wraps = wrap_num - (rem.wrap + ZERO_INDEXED) proxy_row = ((rem.wrap + ZERO_INDEXED) * (rem.new_source_row_count + SPACER) + old_wraps * (rem.old_source_row_count + SPACER) + source_row) else: source_row_count = len(self._source_child_row_counts) proxy_row = wrap_num * (source_row_count + SPACER) + source_row proxy_index = self.index(proxy_row, proxy_col) return proxy_index, True, wrap_num
[docs] def mapToSource(self, proxy_index, *, map_past_end_to_last_col=False): """ Map an index from this proxy to the source model. :param proxy_index: The proxy index to map. :type proxy_index: QtCore.QModelIndex :param map_past_end_to_last_col: If False, indices in the last wrap that are past the end of the source model will be mapped to an invalid index. If True, these indices will be mapped to the last valid index in their row. :type map_past_end_to_last_col: bool """ if not self._source_child_row_counts: # model is empty return QtCore.QModelIndex() (source_row, source_parent_row, wrap_num, is_spacer) = \ self._mapRowToSource(proxy_index.row(), proxy_index.internalId()) if source_row is None: return QtCore.QModelIndex() if source_parent_row is None: source_parent = None else: source_parent = self._source_model.index(source_parent_row, 0) source_col = wrap_num * self._width + proxy_index.column() if source_col >= self._source_column_count: # We're in the final wrap and to the right of the last "real" column if map_past_end_to_last_col: source_col = self._source_column_count - 1 else: return QtCore.QModelIndex() return self._source_model.index(source_row, source_col, source_parent)
def _mapRowToSource(self, proxy_row, proxy_internal_id): """ Determine the source model row that corresponds to the specified proxy row. :param proxy_row: The proxy row to map. :type proxy_row: int :param proxy_internal_id: The parent row (or `TOP_LEVEL` for top-level rows) of the row to map. :type proxy_internal_id: int :return: A tuple of:: - The source row, or None if the row doesn't exist in the source model (for spacer rows in between wraps or source rows that are in the process of being inserted or removed) (int or None) - The source parent row. Will be None for top level rows or if the row doesn't exist in the source model. (int or None) - The wrap that the proxy index is in. Will be None if no source row exists. (int) - Whether this row is a spacer row in between wraps. True if the proxy row is a spacer row. False if the proxy row corresponds to a source row that's in the process of being inserted or removed. This value is only applicable when no equivalent source row exists and will be None otherwise. (bool or None) :rtype: tuple """ if not self._source_child_row_counts: # model is empty return None, None, None, False elif proxy_internal_id == TOP_LEVEL: source_row, wrap_num, is_spacer = self._mapTopLevelRowToSource( proxy_row) return source_row, None, wrap_num, is_spacer else: source_parent_row, wrap_num, is_spacer = \ self._mapTopLevelRowToSource(proxy_internal_id) if source_parent_row is None: return (None, None, None, False) source_row = proxy_row ins = self._inserting_child_rows rem = self._removing_child_rows if (ins is not None and source_parent_row == ins.source_parent_row and wrap_num <= ins.wrap): if source_row > ins.source_end: source_row -= ins.num_new_rows elif source_row >= ins.source_start: return None, None, None, False elif (rem is not None and source_parent_row == rem.source_parent_row and wrap_num <= rem.wrap and source_row >= rem.source_start): source_row += rem.num_rem_rows return source_row, source_parent_row, wrap_num, is_spacer def _mapTopLevelRowToSource(self, proxy_row): """ Determine the source model row that corresponds to the specified top- level proxy row. :param proxy_row: The proxy row to map :type proxy_row: int :return: A tuple of:: - The source row, or None if the row doesn't exist in the source model (for spacer rows in between wraps or source rows that are in the process of being inserted or removed) (int or None) - The wrap that the proxy index is in. Will be None if no source row exists. (int) - None if a source row exists. True if the proxy row is a spacer row in between wraps. False if the proxy row corresponds to a source row that's in the process of being inserted or removed. (bool or None) :rtype: tuple """ ins = self._inserting_rows rem = self._removing_rows if ins is not None: new_rows = (ins.new_source_row_count + SPACER) * (ins.wrap + ZERO_INDEXED) if proxy_row <= new_rows: wrap_num = proxy_row // (ins.new_source_row_count + SPACER) source_row = proxy_row % (ins.new_source_row_count + SPACER) if source_row == ins.new_source_row_count: return None, None, False elif source_row > ins.source_end: source_row -= ins.num_new_rows elif source_row >= ins.source_start: return None, None, False else: wrap_num = ((proxy_row - new_rows) // (ins.old_source_row_count + SPACER) + ins.wrap + ZERO_INDEXED) source_row = ((proxy_row - new_rows) % (ins.old_source_row_count + SPACER)) if source_row == ins.old_source_row_count: return None, None, False elif rem is not None: new_rows = (rem.new_source_row_count + SPACER) * (rem.wrap + ZERO_INDEXED) if proxy_row <= new_rows: wrap_num = proxy_row // (rem.new_source_row_count + SPACER) source_row = proxy_row % (rem.new_source_row_count + SPACER) if source_row == rem.new_source_row_count: return None, None, False elif source_row >= rem.source_start: source_row += rem.num_rem_rows else: wrap_num = ((proxy_row - new_rows) // (rem.old_source_row_count + SPACER) + rem.wrap + ZERO_INDEXED) source_row = ((proxy_row - new_rows) % (rem.old_source_row_count + SPACER)) if source_row == rem.old_source_row_count: return None, None, False else: source_row_count = len(self._source_child_row_counts) wrap_num = proxy_row // (source_row_count + SPACER) source_row = proxy_row % (source_row_count + SPACER) if source_row == source_row_count: # This row is a spacer in between wraps return None, None, True return source_row, wrap_num, None
[docs] def flags(self, index): """ See Qt documentation for method documentation. We override the default behavior here to provide valid flags for indices in the last wrap that are past the end of the source model. This allows selection to work as expected when dragging past the end of the last column since it preserves the Qt.ItemIsSelectable flag. """ source_index = self.mapToSource(index, map_past_end_to_last_col=True) return self.sourceModel().flags(source_index)
[docs] def data(self, index, role=Qt.DisplayRole, multiple_roles=None): """ Returns the data stored under the given `role` for the item referred to by `index`. If `role` is `CustomRole.MultipleRoles`, then data is returned for all roles specified in `multiple_roles`. See Qt documentation for additional information. :param index: The index to fetch data for. :type index: QModelIndex :param role: The role to fetch data for. :type role: int :param multiple_roles: A list of roles to fetch data. Only applies if `role` is `CustomRole.MultipleRoles`. :type multiple_roles: list(int) or NoneType :return: The data specified by `role` for `index`, or a dictionary mapping roles to their values if multiple roles are requested. :rtype: object or dict(int, object) """ # Note that we must not call rowData here. That will cause data methods # in AnnotationProxyModel to be called without the source_index # argument, and that argument is required for the drag-and-drop data # methods (_includeInDragImageData, _canDropAboveData, and # _canDropBelowData). if not index.isValid(): return {} if role == CustomRole.MultipleRoles else None source_row, source_parent_row, wrap_num, is_spacer = \ self._mapRowToSource(index.row(), index.internalId()) if is_spacer: if role == CustomRole.RowType: return RowType.Spacer elif role == CustomRole.MultipleRoles: return {CustomRole.RowType: RowType.Spacer} else: return None elif source_row is None: # the proxy_row corresponds to a source row that's in the process of # being added or removed return {} if role == CustomRole.MultipleRoles else None else: source_col = wrap_num * self._width + index.column() if source_col >= self._source_column_count: # the index is in the last wrap and past the end of the # alignment return {} if role == CustomRole.MultipleRoles else None if source_parent_row is None: source_parent = None else: source_parent = self._source_model.index(source_parent_row, 0) source_index = self._source_model.index(source_row, source_col, source_parent) return self._source_model.data(source_index, role, multiple_roles)
[docs] def rowData(self, proxy_row, proxy_cols, proxy_internal_id, roles): """ Fetch data for multiple roles for multiple indices in the same row. Note that this method does minimal sanity checking of its input for performance reasons, as it is called during painting. The arguments are assumed to refer to valid indices. Use `data` instead if more sanity checking is required. :param row: The row number to fetch data for. :type row: int :param proxy_cols: A list of columns to fetch data for. Columns numbers must be sorted in ascending order. :type proxy_cols: list(int) :param proxy_internal_id: The parent row (or `TOP_LEVEL` for top- level rows) of the row to fetch data for. :type proxy_internal_id: int :param roles: A list of roles to fetch data for. :type roles: list(int) :return: {role: data} dictionaries for each requested column. Note that the keys of these dictionaries may not match `roles`. Data for additional roles may be included (e.g. if that data was required to calculate data for a requested role). Data for requested roles may not be included if those roles are not applicable to the specified row (i.e. spacer rows do not provide data beyond row type and spacer type). :rtype: list(dict(int, object)) """ source_row, source_parent_row, wrap_num, is_spacer = \ self._mapRowToSource(proxy_row, proxy_internal_id) if is_spacer: if CustomRole.RowType in roles: return [{ CustomRole.RowType: RowType.Spacer } for _ in proxy_cols] else: return [{} for _ in proxy_cols] elif source_row is None: # the proxy_row corresponds to a source row that's in the process of # being added or removed return [{} for _ in proxy_cols] source_col_at_wrap_start = wrap_num * self._width source_cols = [] for cur_proxy_col in proxy_cols: cur_source_col = source_col_at_wrap_start + cur_proxy_col if cur_source_col >= self._source_column_count: # the proxy_row is in the last wrap and columns were requested # that are past the end of the alignment. cols_truncated = True break source_cols.append(cur_source_col) else: cols_truncated = False if source_parent_row is None: source_internal_id = TOP_LEVEL else: source_internal_id = source_parent_row row_data = self._source_model.rowData(source_row, source_cols, source_internal_id, roles) if cols_truncated: num_missing_cols = len(proxy_cols) - len(source_cols) row_data.extend({} for _ in range(num_missing_cols)) return row_data
[docs] def headerData(self, section, orientation, role=Qt.DisplayRole): # See Qt documentation for argument documentation return None
def _sourceModelReset(self): """ Respond to the modelChanged signal from the source model. """ self._resetColumnCount() self._resetRowCounts() self.endResetModel() def _sourceLayoutChanged(self): """ Respond to the layoutChanged signal from the source model. """ self._resetRowCounts() self._invalidateAllPersistentIndices() self.layoutChanged.emit() def _sourceRowsAboutToBeInserted(self, parent, source_start, source_end): """ Respond to the rowsAboutToBeInserted signal from the source model. This method will completely insert (i.e. rowsAboutToBeInserted and rowsInserted) dummy rows into each wrap of this proxy. After the source model finishes the insertion, the dummy rows will be updated (dataChanged and inserting child rows) in `_sourceRowsInserted`. This allows us to avoid nesting the row insertions (i.e. emitting multiple rowsAboutToBeInserted signals followed by multiple rowsInserted signals), as nested signals are more complicated for higher proxies to handle and sometimes lead to errors from QSortFilterProxyModels. See `QAbstractItemModel.rowsAboutToBeInserted` signal documentation for argument documentation. """ num_to_insert = source_end - source_start + 1 assert num_to_insert > 0 if not parent.isValid(): old_source_row_count = len(self._source_child_row_counts) new_source_row_count = old_source_row_count + num_to_insert ins = RowWrapInsertingRows(old_source_row_count, new_source_row_count, source_start, source_end) if old_source_row_count == 0: # If the source model was empty then none of our wraps have # spacer rows yet, so we need to handle this insertion as a # special case. if source_start != 0: # The source model is trying to insert new rows after # existing rows, but there aren't any existing rows. err = ("In RowWrapProxyModel, source model reports invalid " "rows to be inserted.") raise RuntimeError(err) new_proxy_row_count = self._wrap_count * (new_source_row_count + SPACER) - SPACER self.beginInsertRows(QtCore.QModelIndex(), 0, new_proxy_row_count - 1) self._top_level_row_count = new_proxy_row_count ins.wrap += self._wrap_count self.endInsertRows() else: for wrap_num in range(self._wrap_count): start = (wrap_num * (new_source_row_count + SPACER) + source_start) self.beginInsertRows(QtCore.QModelIndex(), start, start + num_to_insert - 1) if wrap_num == 0: self._inserting_rows = ins ins.wrap += 1 self._top_level_row_count += num_to_insert self.endInsertRows() self._inserting_rows = None self._updating_inserted_rows = ins else: source_parent_row = parent.row() source_row_count = len(self._source_child_row_counts) ins_child = RowWrapInsertingChildRows(source_parent_row, source_start, source_end) for wrap_num in range(self._wrap_count): proxy_parent_row = (wrap_num * (source_row_count + SPACER) + source_parent_row) proxy_parent_index = self.index(proxy_parent_row, 0) self.beginInsertRows(proxy_parent_index, source_start, source_end) if wrap_num == 0: self._inserting_child_rows = ins_child self._source_child_row_counts[source_parent_row] += \ num_to_insert ins_child.wrap += 1 self.endInsertRows() def _sourceRowsInserted(self, parent, source_start, source_end): """ Respond to the rowsInserted signal from the source model. Note that new rows were already inserted in `_sourceRowsAboutToBeInserted`, so this method updates the new rows by emitting `dataChanged` and inserting any child rows. See `QAbstractItemModel.rowsInserted` signal documentation for argument documentation. """ num_to_insert = source_end - source_start + 1 assert num_to_insert > 0 last_col = self.columnCount() - 1 if not parent.isValid(): ins = self._updating_inserted_rows if ins is None: err = ("In RowWrapProxyModel, got rowsInserted for top-level " "rows without first receiving rowsAboutToBeInserted for " "top-level rows.") raise RuntimeError(err) source_model = self.sourceModel() rows_per_wrap = source_model.rowCount() + SPACER row_counts = [] for cur_source_row in range(source_start, source_end + 1): index = source_model.index(cur_source_row, 0) cur_row_count = source_model.rowCount(index) row_counts.append(cur_row_count) self._source_child_row_counts[source_start:source_start] = \ row_counts for wrap_num in range(self._wrap_count): top_proxy_row = wrap_num * rows_per_wrap + source_start bottom_proxy_row = wrap_num * rows_per_wrap + source_end proxy_row_nums = range(top_proxy_row, bottom_proxy_row + 1) for cur_proxy_row, cur_row_count in zip(proxy_row_nums, row_counts): if not cur_row_count: continue index = self.index(cur_proxy_row, 0) self.beginInsertRows(index, 0, cur_row_count - 1) ins.update_row_num = cur_proxy_row self.endInsertRows() top_left = self.index(top_proxy_row, 0) bottom_right = self.index(bottom_proxy_row, last_col) self.dataChanged.emit(top_left, bottom_right) self._updating_inserted_rows = None else: if self._inserting_child_rows is None: err = ("In RowWrapProxyModel, got rowsInserted for child rows " "without first receiving rowsAboutToBeInserted for " "child rows.") raise RuntimeError(err) self._inserting_child_rows = None source_parent_row = parent.row() rows_per_wrap = len(self._source_child_row_counts) + 1 for wrap_num in range(self._wrap_count): proxy_parent_row = wrap_num * rows_per_wrap + source_parent_row proxy_parent_index = self.index(proxy_parent_row, 0) top_left = self.index(source_start, 0, proxy_parent_index) bottom_right = self.index(source_end, last_col, proxy_parent_index) self.dataChanged.emit(top_left, bottom_right) def _sourceRowsAboutToBeRemoved(self, parent, source_start, source_end): """ Respond to the rowsAboutToBeRemoved signal from the source model. Note that this method completely removes (i.e. rowsAboutToBeRemoved and rowsRemoved) rows from all wraps of this proxy. See `QAbstractItemModel.rowsAboutToBeRemoved` signal documentation for argument documentation. """ num_to_remove = source_end - source_start + 1 assert num_to_remove > 0 if not parent.isValid(): old_source_row_count = len(self._source_child_row_counts) new_source_row_count = old_source_row_count - num_to_remove rem = RowWrapRemovingRows(old_source_row_count, new_source_row_count, source_start, source_end) for wrap_num in range(self._wrap_count): start = (wrap_num * (new_source_row_count + SPACER) + source_start) self.beginRemoveRows(QtCore.QModelIndex(), start, start + num_to_remove - 1) if wrap_num == 0: self._removing_rows = rem rem.wrap += 1 self._top_level_row_count -= num_to_remove self.endRemoveRows() else: source_parent_row = parent.row() rows_per_wrap = len(self._source_child_row_counts) + SPACER rem_child = RowWrapRemovingChildRows(source_parent_row, source_start, source_end) for wrap_num in range(self._wrap_count): proxy_parent_row = wrap_num * rows_per_wrap + source_parent_row proxy_parent_index = self.index(proxy_parent_row, 0) self.beginRemoveRows(proxy_parent_index, source_start, source_end) if wrap_num == 0: self._removing_child_rows = rem_child self._source_child_row_counts[source_parent_row] -= \ num_to_remove self._removing_child_rows.wrap += 1 self.endRemoveRows() def _sourceRowsRemoved(self, parent, source_start, source_end): """ Respond to the rowsRemoved signal from the source model. Note that the rows were already removed in `_sourceRowsAboutToBeRemoved`, so this method just clears the row removal bookkeeping. See `QAbstractItemModel.rowsRemoved` signal documentation for argument documentation. """ num_to_remove = source_end - source_start + 1 assert num_to_remove > 0 if not parent.isValid(): if self._removing_rows is None: err = ("In RowWrapProxyModel, got rowsRemoved for top-level " "rows without first receiving rowsAboutToBeRemoved for " "top-level rows.") raise RuntimeError(err) rem = self._removing_rows if rem.source_start != source_start or rem.source_end != source_end: err = ("In RowWrapProxyModel, row numbers for top-level " "rowsRemoved signal do not match row numbers from " "rowsAboutToBeRemoved signal.") raise RuntimeError(err) self._removing_rows = None self._source_child_row_counts[source_start:source_end + 1] = [] else: if self._removing_child_rows is None: err = ("In RowWrapProxyModel, got rowsRemoved for child rows " "without first receiving rowsAboutToBeRemoved for " "child rows.") raise RuntimeError(err) self._removing_child_rows = None def _sourceColumnsAboutToBeInserted(self, parent, start, end): """ Respond to the columnsAboutToBeInserted signal from the source model. See `QAbstractItemModel.columnsAboutToBeInserted` signal documentation for argument documentation. """ if parent.isValid(): # We assume that all groups have the same number of columns, so # ignore signals for everything but the top level group return old_source_cols = self._source_column_count source_cols_to_insert = end - start + 1 new_source_cols = old_source_cols + source_cols_to_insert old_wrap_count = self._wrapCount(old_source_cols) new_wrap_count = self._wrapCount(new_source_cols) wraps_added = new_wrap_count - old_wrap_count if old_wrap_count == 1 and old_source_cols < self._width: new_proxy_cols = min(new_source_cols, self._width) self.beginInsertColumns(QtCore.QModelIndex(), self._column_count, new_proxy_cols - ZERO_INDEXED) self._column_count = new_proxy_cols self.endInsertColumns() if wraps_added: if self._top_level_row_count: source_row_count = len(self._source_child_row_counts) new_row_count = (self._top_level_row_count + wraps_added * (source_row_count + SPACER)) self.beginInsertRows(QtCore.QModelIndex(), self._top_level_row_count, new_row_count - ZERO_INDEXED) self._top_level_row_count = new_row_count self._wrap_count = new_wrap_count self.endInsertRows() else: self._wrap_count = new_wrap_count def _sourceColumnsAboutToBeRemoved(self, parent, start, end): """ Respond to the columnsAboutToBeRemoved signal from the source model. See `QAbstractItemModel.columnsAboutToBeRemoved` signal documentation for argument documentation. """ if parent.isValid(): # We assume that all groups have the same number of columns, so # ignore signals for everything but the top level group return old_source_cols = self._source_column_count source_cols_to_remove = end - start + 1 new_source_cols = old_source_cols - source_cols_to_remove old_wrap_count = self._wrapCount(old_source_cols) new_wrap_count = self._wrapCount(new_source_cols) wraps_removed = old_wrap_count - new_wrap_count if wraps_removed: if self._top_level_row_count: source_row_count = len(self._source_child_row_counts) new_row_count = (self._top_level_row_count - wraps_removed * (source_row_count + SPACER)) self.beginRemoveRows(QtCore.QModelIndex(), new_row_count, self._top_level_row_count - ZERO_INDEXED) self._top_level_row_count = new_row_count self._wrap_count = new_wrap_count self.endRemoveRows() else: self._wrap_count = new_wrap_count if new_wrap_count == 1 and new_source_cols < self._width: self.beginRemoveColumns(QtCore.QModelIndex(), new_source_cols, self._column_count - ZERO_INDEXED) self._column_count = new_source_cols self.endRemoveColumns() def _sourceColumnsInsertedOrRemoved(self, parent, start, end): if parent.isValid(): # We assume that all groups have the same number of columns, so # ignore signals for everything but the top level group return self._source_column_count = self.sourceModel().columnCount() # If we wanted to emit dataChanged for all affected cells, we'd have to # iterate through all rows with children and emit a separate dataChanged # signal for each group. Doing that will almost certainly take longer # than just repainting the entire visible area. invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) def _sourceDataChanged(self, source_topleft, source_bottomright): """ Respond to a dataChanged signal from the source model by emitting dataChanged for all affected cells. See `QAbstractItemModel.dataChanged` signal documentation for argument documentation. """ if not source_topleft.isValid() and not source_bottomright.isValid(): self.dataChanged.emit(source_topleft, source_bottomright) source_parent = source_topleft.parent() source_row_start = source_topleft.row() source_row_end = source_bottomright.row() num_source_rows = len(self._source_child_row_counts) last_col = self._width - ZERO_INDEXED if not source_parent.isValid(): proxy_topleft, is_topleft_valid, top_wrap = \ self._mapTopLevelIndexFromSource(source_topleft) proxy_bottomright, is_bottomright_valid, bottom_wrap = \ self._mapTopLevelIndexFromSource(source_bottomright) if not is_topleft_valid or not is_bottomright_valid: err = ("Invalid proxy index in %s._modelDataChanged" % self.__class__.__name__) raise RuntimeError(err) left_col = proxy_topleft.column() right_col = proxy_bottomright.column() for cur_wrap in range(top_wrap, bottom_wrap + 1): cur_left_col = left_col if cur_wrap == top_wrap else 0 cur_right_col = (right_col if cur_wrap == bottom_wrap else last_col) cur_wrap_start = cur_wrap * (num_source_rows + SPACER) cur_top_row = cur_wrap_start + source_row_start cur_bottom_row = cur_wrap_start + source_row_end cur_topleft = self.index(cur_top_row, cur_left_col) cur_bottomright = self.index(cur_bottom_row, cur_right_col) self.dataChanged.emit(cur_topleft, cur_bottomright) else: proxy_topleft = self.mapFromSource(source_topleft) proxy_bottomright = self.mapFromSource(source_bottomright) if not (proxy_topleft.isValid() and proxy_bottomright.isValid()): err = ("Invalid proxy index in %s._modelDataChanged" % self.__class__.__name__) raise RuntimeError(err) proxy_top_parent = proxy_topleft.parent() proxy_bottom_parent = proxy_bottomright.parent() top_parent_row = proxy_top_parent.row() bottom_parent_row = proxy_bottom_parent.row() top_wrap = top_parent_row // (num_source_rows + 1) bottom_wrap = bottom_parent_row // (num_source_rows + 1) left_col = proxy_topleft.column() right_col = proxy_bottomright.column() source_parent_row = source_parent.row() for cur_wrap in range(top_wrap, bottom_wrap + 1): cur_left_col = left_col if cur_wrap == top_wrap else 0 cur_right_col = (right_col if cur_wrap == bottom_wrap else last_col) cur_parent_row = (cur_wrap * (num_source_rows + SPACER) + source_parent_row) cur_parent_index = self.index(cur_parent_row, 0) cur_topleft = self.index(source_row_start, cur_left_col, cur_parent_index) cur_bottomright = self.index(source_row_end, cur_right_col, cur_parent_index) self.dataChanged.emit(cur_topleft, cur_bottomright) def _seqExpansionChanged(self, source_indices, expanded): # See SeqExpansionProxyMixin._seqExpansionChanged for method # documentation proxy_indices = [] rows_per_wrap = len(self._source_child_row_counts) + SPACER for source_index in source_indices: source_row = source_index.row() for cur_wrap in range(self._wrap_count): proxy_row = cur_wrap * rows_per_wrap + source_row proxy_index = self.index(proxy_row, 0) proxy_indices.append(proxy_index) self.seqExpansionChanged.emit(proxy_indices, expanded) @QtCore.pyqtSlot(int, int) def _sourceFixedColumnDataChanged(self, role, source_row): # See method documentation in ProxyMixin rows_per_wrap = len(self._source_child_row_counts) + SPACER for cur_wrap in range(self._wrap_count): proxy_row = cur_wrap * rows_per_wrap + source_row self.fixedColumnDataChanged.emit(role, proxy_row)
[docs] def rowWrapEnabled(self): """ :return: Whether this model provides row-wrapped data. :type: bool .. warning: You probably don't want to use this method. Whenever possible, you should pass information to the model and have it respond as appropriate (i.e. tell, don't ask). This method should only be used for view features that function differently depending on whether the model is row-wrapped or not (e.g. drag-and-drop auto-scrolling). """ return True
[docs] def setResRangeSelectionState(self, from_index, to_index, selected, columns, current=False): # See SequenceAlignmentModel.setResRangeSelectionState for method # documentation. from_source_index = self.mapToSource(from_index, map_past_end_to_last_col=True) to_source_index = self.mapToSource(to_index, map_past_end_to_last_col=True) self.sourceModel().setResRangeSelectionState(from_source_index, to_source_index, selected, columns, current)
[docs]class ExportProxyModel(PostAnnotationProxyMixin, QtCore.QIdentityProxyModel): """ A proxy for use when generating a static image of the table. """
[docs] def tableWidthChanged(self, *args, **kwargs): """ Ignore changes in the table size rather than trying to update row wrapping. """
# This method intentionally left blank
[docs] def rowData(self, proxy_row, proxy_cols, proxy_internal_id, roles): """ See `AnnotationProxyModel.rowData` for method documentation. """ # This method assumes that QIdentityProxyModel uses the same internal # ids as the source model. That's currently the case, but internal ids # are considered implementation details so it's possible (although # highly unlikely) that internal ids would change. If that happens, # this method would need to remap the internal id using # self.mapToSource. return self.sourceModel().rowData(proxy_row, proxy_cols, proxy_internal_id, roles)
[docs]class ExportLogoProxyModel(PostAnnotationProxyMixin, QtCore.QSortFilterProxyModel): """ Sort proxy model which only accepts the ruler, spacer, and sequence logo rows. Used when exporting an image of just the sequence logo. """
[docs] def filterAcceptsRow(self, source_row, parent_index): source_index = self.sourceModel().index(source_row, 0) row_type = self.sourceModel().data(source_index, CustomRole.RowType) return row_type in (RowType.Spacer, ALN_ANNO_TYPES.sequence_logo, ALN_ANNO_TYPES.indices)
[docs] def tableWidthChanged(self, *args, **kwargs): """ Ignore changes in the table size rather than trying to update row wrapping. """
# This method intentionally left blank
[docs] def rowData(self, proxy_index, proxy_cols, roles): """ This method differs from typical rowData methods in that it takes a proxy index instead of a row number, because it must first map that index to source. This is because the QSortFilterProxyModel uses a different indexing/internal id system than other proxy models in MSV. """ source_index = self.mapToSource(proxy_index) return self.sourceModel().rowData(source_index.row(), proxy_cols, source_index.internalId(), roles)
[docs]class ViewModel(QtCore.QObject): """ An abstraction layer for the various table models in this module :cvar orderChanged: A signal emitted when the sorting or row reodering (due to drag-and-drop) has changed. Emitted with the new seq indices. :vartype orderChanged: `QtCore.pyqtSignal` :cvar topModelChanged: A signal emitted when row wrapping is toggled. The view must change models whenever this signal is emitted. Emitted with the new model that the view should use. :vartype topModelChanged: `QtCore.pyqtSignal` """ orderChanged = QtCore.pyqtSignal(list) topModelChanged = QtCore.pyqtSignal(QtCore.QAbstractItemModel)
[docs] def __init__(self, parent): super().__init__(parent) self.undo_stack = None self._base_model = SequenceAlignmentModel(parent) self._base_model.sequencesReordered.connect(self.orderChanged) self._base_model.hiddenSeqsChanged.connect(self._setFilter) self.setAlignment = self._base_model.setAlignment self.getAlignment = self._base_model.getAlignment self.getResidueDisplayMode = self._base_model.getResidueDisplayMode self.updateColorScheme = self._base_model.updateColorScheme self.getSeqColorScheme = self._base_model.getSeqColorScheme self.updateResidueColors = self._base_model.updateResidueColors self.getResidueColors = self._base_model.getResidueColors self.sequenceCount = self._base_model.sequenceCount self._filter_proxy = SequenceFilterProxyModel(parent) self._filter_proxy.setSourceModel(self._base_model) self._annotation_proxy = AnnotationProxyModel(parent) # `AnnotationProxyModel.setSourceModel` does bookkeeping that needs to # know about options so we set a dummy options model so it can run. The # real options model is set later and will update the bookkeeping self._annotation_proxy.setOptionsModel(gui_models.OptionsModel()) self._annotation_proxy.setSourceModel(self._base_model) ann_proxy = self._annotation_proxy self.getGroupBy = ann_proxy.getGroupBy self._setShownRowTypes = ann_proxy._setShownRowTypes self.getShownRowTypes = ann_proxy.getShownRowTypes self._setVisibilityForRowType = ann_proxy._setVisibilityForRowType self._setVisibilityForRowTypes = ann_proxy._setVisibilityForRowTypes self._wrap_proxy = RowWrapProxyModel(parent) self._wrap_proxy.setSourceModel(self._annotation_proxy) self._wrap_enabled = False self.top_model = self._annotation_proxy self.export_model = ExportProxyModel(parent) self.export_model.setSourceModel(self.top_model) self.info_model = AlignmentInfoProxyModel(parent) self.info_model.setSourceModel(self.top_model) self.orderChanged.connect(self.info_model.orderChanged) self.metrics_model = AlignmentMetricsProxyModel(parent) self.metrics_model.setSourceModel(self.top_model)
[docs] def setStructureModel(self, smodel): self._base_model.setStructureModel(smodel)
[docs] def setLightMode(self, enabled): """ Set light mode on the info and metrics tables and appropriately change the color scheme for some annotations """ self._base_model.setLightMode(enabled) self.info_model.setLightMode(enabled) self.metrics_model.setLightMode(enabled) if enabled: scheme = color.LightModeTextScheme() else: scheme = color.DarkModeTextScheme() for ann_type in color.TEXT_ANN_TYPES: self.updateColorScheme(ann_type, scheme)
def _setFilter(self, enabled): """ Enable or disable sequence filtering. :param enabled: Whether sequences should be filtered. :type enabled: bool """ if enabled: annotation_source = self._filter_proxy else: annotation_source = self._base_model self._filter_proxy.setActive(enabled) self._annotation_proxy.setSourceModel(annotation_source)
[docs] def setRowWrap(self, enabled): """ Enable or disable row wrapping. :param enabled: Whether rows should be wrapped. :type enabled: bool """ if enabled == self._wrap_enabled: return if enabled: self.top_model = self._wrap_proxy else: self.top_model = self._annotation_proxy self.topModelChanged.emit(self.top_model) self.export_model.setSourceModel(self.top_model) self.info_model.setSourceModel(self.top_model) self.metrics_model.setSourceModel(self.top_model) self._wrap_enabled = enabled
[docs] def rowWrap(self): """ Return the current row wrapping setting. :return: True if the rows are wrapped. False otherwise. :rtype: bool """ return self._wrap_enabled
[docs] def setPageModel(self, page_model): """ Set the page model, which contains the options model and is also responsible for switching between split-chain and combined-chain alignments. :param page_model: The page model to set :type page_model: gui_models.PageModel """ self._base_model.setPageModel(page_model) self.metrics_model.setOptionsModel(page_model.options) self._annotation_proxy.setOptionsModel(page_model.options)
[docs]class FixedColumn(table_helper.Column): """ A table column that is fixed on the left or right of the scrollable columns. This object is intended to be used in the `table_helper.TableColumns` enum. """
[docs] def __init__(self, title, tooltip=None, role=None, percent=False, all_rows=False, align=Qt.AlignCenter, selectable=False): """ :param title: The title to display in the column header. :type title: basestring :param tooltip: The tooltip to display when the user hovers over the column header. :type tooltip: str :param role: The role that the column should display data from. May be None if no data is to be displayed in the column (e.g. the column that contains only group expansion arrows). :type role: int or NoneType :param percent: Whether to format a value as a percentage (without '%' sign) :type percent: bool :param all_rows: True if the column contains data for all rows. False if the column only contains data for sequence rows. :type all_rows: bool :param align: The alignment for cells in the column. Note that only the horizontal component of the alignment is obeyed; cells are always vertically aligned to center. :type align: int :param selectable: Whether this column should be flagged as selectable (i.e. Qt.ItemIsSelectable). :type selectable: bool """ self.data = { "title": title, "tooltip": tooltip, "role": role, "percent": percent, "all_rows": all_rows, "align": align, "selectable": selectable } self._count = self.__class__._count self.__class__._count += 1
[docs]class SequencePropertyColumn(FixedColumn):
[docs] def __init__(self, seq_prop, role, *args, **kwargs): super().__init__(*args, role=role, **kwargs) # During creation of a TableColumns class, the items of `data` get # converted to instance attributes. Since instances of # SequencePropertyColumn aren't added to AlignmentMetricsColumns, # we manually do the conversion here. for key, val in self.data.items(): setattr(self, key, val) self._seq_prop = seq_prop
[docs] def getSeqProp(self): return self._seq_prop
[docs]class BaseAdjacentAlignmentProxyModel( table_speed_up.MultipleRolesRoleProxyMixin, SeqExpansionProxyMixin, GroupByProxyMixin, GetAlignmentProxyMixin, NestedProxy): """ A base proxy model to be subclassed by other proxy models that show data related to and synchronized with an alignment but in separate, adjacent views. :cvar ROLES_FROM_SOURCE: Set of data roles to query from the source model. :vartype ROLES_FROM_SOURCE: set """ Column = None FONT_SCALE = 1.25 textSizeChanged = QtCore.pyqtSignal() rowHeightChanged = QtCore.pyqtSignal() ROLES_FROM_SOURCE = { CustomRole.RowType, CustomRole.ReferenceSequence, CustomRole.Seq, CustomRole.RowHeightScale, CustomRole.SeqSelected } ROLES_FROM_PROXY = { Qt.DisplayRole, Qt.TextAlignmentRole, Qt.FontRole, Qt.ToolTipRole }
[docs] def __init__(self, parent=None): super(BaseAdjacentAlignmentProxyModel, self).__init__(parent) self.ROLE_TO_COL = {col.role: i for i, col in enumerate(self.Column)} self.DEFINED_ROLES = (self.ROLES_FROM_SOURCE | self.ROLES_FROM_PROXY | {CustomRole.MultipleRoles}) self._regular_font = QtGui.QFont() font_size = int(self._regular_font.pointSize() * self.FONT_SCALE) self._regular_font.setPointSize(font_size) # Using self._cur_columns instead of self.Column allows the same code to # work with both AlignmentInfoProxyModel and AlignmentMetricsProxyModel self._cur_columns = [col for col in self.Column] self._selectable_cols = [ i for i, col in enumerate(self.Column) if col.selectable ]
[docs] @table_helper.model_reset_method def setSourceModel(self, model): # See Qt documentation for method documentation. signal_map = { "modelAboutToBeReset": self.beginResetModel, "modelReset": self.endResetModel, "layoutAboutToBeChanged": self.layoutAboutToBeChanged, "layoutChanged": self.endLayoutChange, "rowsAboutToBeInserted": self._sourceRowsAboutToBeInserted, "rowsInserted": self.endInsertRows, "rowsAboutToBeRemoved": self._sourceRowsAboutToBeRemoved, "rowsRemoved": self.endRemoveRows, "textSizeChanged": self._textSizeChanged, "fixedColumnDataChanged": self.updateData, "rowHeightChanged": self._rowHeightChanged, "dataChanged": self._sourceDataChanged } old_model = self.sourceModel() if old_model is not None: table_helper.disconnect_signals(old_model, signal_map) table_helper.connect_signals(model, signal_map) super(BaseAdjacentAlignmentProxyModel, self).setSourceModel(model)
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation if parent is None or not parent.isValid(): return self.sourceModel().rowCount() else: source_parent = self.mapToSource(parent) return self.sourceModel().rowCount(source_parent)
[docs] def mapToSource(self, proxy_index): # See Qt documentation for method documentation if not proxy_index.isValid(): return QtCore.QModelIndex() source_model = self.sourceModel() internal_id = proxy_index.internalId() if internal_id == TOP_LEVEL: source_parent = QtCore.QModelIndex() else: source_parent = source_model.index(internal_id, 0) return source_model.index(proxy_index.row(), 0, source_parent)
[docs] def mapFromSource(self, source_index): # See Qt documentation for method documentation if not source_index.isValid() or not self._cur_columns: return QtCore.QModelIndex() source_model = self.sourceModel() source_parent = source_model.parent(source_index) internal_id = source_parent.row() if internal_id == -1: internal_id = TOP_LEVEL return self.createIndex(source_index.row(), 0, internal_id)
[docs] def data(self, proxy_index, role=Qt.DisplayRole, multiple_roles=None): # See table_speed_up.MultipleRolesRoleModelMixin.data for method # documentation if role not in self.DEFINED_ROLES: return None source_index = self.mapToSource(proxy_index) source_model = self.sourceModel() if role not in self._data_methods: return source_model.data(source_index, role) col_num = proxy_index.column() col_obj = self._cur_columns[col_num] is_title_row = (proxy_index.row() == 0 and proxy_index.internalId() == TOP_LEVEL) if role == CustomRole.MultipleRoles: data_args = (col_obj, is_title_row, proxy_index, source_index, source_model, multiple_roles) else: data_args = (col_obj, is_title_row, proxy_index, source_index, source_model, None, role) return self._callDataMethod(role, data_args)
@table_helper.data_method(Qt.TextAlignmentRole) def _alignmentData(self, col): return col.align @table_helper.data_method(Qt.FontRole) def _fontData(self): return self._regular_font
[docs] def rowData(self, proxy_row, cols, internal_id, roles): """ Fetch data for multiple roles for multiple indices in the same row. Note that this method does minimal sanity checking of its input for performance reasons, as it is called during painting. The arguments are assumed to refer to valid indices. Use `data` instead if more sanity checking is required. :param row: The row number to fetch data for. :type row: int :param cols: A list of columns to fetch data for. :type cols: list(int) :param internal_id: The parent row (or `TOP_LEVEL` for top-level rows) of the row to fetch data for. :type internal_id: int :param roles: A list of roles to fetch data for. :type roles: list(int) :return: {role: data} dictionaries for each requested column. Note that the keys of these dictionaries may not match `roles`. Data for additional roles may be included (e.g. if that data was required to calculate data for a requested role). Data for requested roles may not be included if those roles are not applicable to the specified row (e.g. many roles are not applicable to spacer rows). :rtype: list(dict(int, object)) """ is_title_row = internal_id == TOP_LEVEL and proxy_row == 0 roles = set(roles) cols = [self._cur_columns[i] for i in cols] # We pack the requested tuple (and unpack it below) since # AlignmentInfoProxyModel reimplements _mapRolesToSource and # _fetchProxyRowData with different requested roles. This way, # AlignmentInfoProxyModel doesn't have to reimplement this method. (roles, proxy_roles, *requested) = self._mapRolesToSource(is_title_row, cols, roles) source_data = self.sourceModel().rowData(proxy_row, [0], internal_id, roles)[0] roles |= proxy_roles return self._fetchProxyRowData(roles, source_data, cols, is_title_row, *requested)
def _mapRolesToSource(self, is_title_row, cols, roles): """ Given a list of roles and columns to fetch data for, figure out what roles we need to request from the source model and what data can be calculated in this proxy. :param is_title_row: Whether this row is the title row (i.e. the first row of the view). :type is_title_row: bool :param cols: A list of proxy column to fetch data for. :type cols: iterable(FixedColumn) :param roles: A set of roles to fetch data for. :type roles: set(int) :return: A tuple of: - Roles to fetch from the source model - Roles of data that can be calculated in this proxy - Whether Qt.DisplayRole data was requested :rtype: tuple(set(int), set(int), bool) """ display_data_requested = Qt.DisplayRole in roles roles.discard(Qt.DisplayRole) proxy_roles = roles & self.ROLES_FROM_PROXY roles &= self.ROLES_FROM_SOURCE if display_data_requested and not is_title_row: roles.add(CustomRole.RowType) for cur_col in cols: if cur_col.role is not None: roles.add(cur_col.role) return roles, proxy_roles, display_data_requested def _fetchProxyRowData(self, roles, source_data, cols, is_title_row, display_data_requested): """ After data has been retrieved from the source model, determine data for all requested roles for all requested columns. :param roles: The roles to fetch data for. :type roles: set(int) :param source_data: Data for this row that has been retrieved from the source model. :type source_data: dict(int, object) :param cols: The columns to fetch data for. :type cols: list(int) :param is_title_row: Whether this row is the title row (i.e. the first row of the view). :type is_title_row: bool :param display_data_requested: Whether Qt.DisplayRole data was requested. This role will not be in `roles` since `self._displayData` calls `self._multipleRolesData`, which calls this method, so fetching this value through the normal mechanisms would lead to an infinite loop. :type display_data_requested: bool """ row_data = [] for cur_col in cols: cell_data = source_data.copy() if roles: self._fetchMultipleRoles(cell_data, roles, cur_col, is_title_row) if display_data_requested: self._displayDataFromSourceData(cell_data, cur_col, is_title_row) row_data.append(cell_data) return row_data def _displayDataFromSourceData(self, data, col, is_title_row): """ Determine the Qt.DisplayRole data for the specified column. The requested data will be added to the `data` dictionary. :param data: Data from the source model for this row in the form of {role: value}. :type data: dict(int, object) :param col: The column to fetch display data for. :type col: FixedColumn :param is_title_row: Whether this row is the title row (i.e. the first row of the view). :type is_title_row: bool """ if is_title_row: data[Qt.DisplayRole] = col.title elif (col.role is None or (not col.all_rows and data.get(CustomRole.RowType) is not RowType.Sequence)): data[Qt.DisplayRole] = None else: display_data = data.get(col.role) if display_data is None: data[Qt.DisplayRole] = display_data elif not col.percent: data[Qt.DisplayRole] = str(display_data) else: data[Qt.DisplayRole] = str(round(display_data * 100)) @table_helper.data_method(CustomRole.MultipleRoles) def _multipleRolesData(self, col, is_title_row, proxy_index, source_index, source_model, multiple_roles): multiple_roles = set(multiple_roles) multiple_roles, proxy_roles, *requested = \ self._mapRolesToSource(is_title_row, [col], multiple_roles) if multiple_roles: data = source_model.data(source_index, CustomRole.MultipleRoles, multiple_roles) else: data = {} multiple_roles |= proxy_roles if multiple_roles: self._fetchMultipleRoles(data, multiple_roles, col, is_title_row) return self._fetchProxyRowData(multiple_roles, data, [col], is_title_row, *requested)[0] @table_helper.data_method(Qt.DisplayRole) def _displayData(self, col, is_title_row, proxy_index, source_index, source_model): data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, {Qt.DisplayRole}) return data[Qt.DisplayRole] @table_helper.data_method(Qt.ToolTipRole) def _toolTipData(self, col, is_title_row, proxy_index, source_index, source_model): """ Returns the column tooltip (if set) for the first row. :param col: Column to get tooltip for :type col: int :param is_title_row: Whether this is the title row or not :type is_title_row: bool :param proxy_index: The proxy index for which we need a tooltip :type proxy_index: `QtCore.QModelIndex` :param source_index: The source index for which we need a tooltip :type source_index: `QtCore.QModelIndex` :param source_model: The source model to map to :type source_model: `QtWidgets.QAbstractItemModel` :return: A tooltip for the specified column or None :rtype: str or None """ if is_title_row: return col.tooltip return self._nonHeaderToolTipData(col, proxy_index, source_index, source_model) def _nonHeaderToolTipData(self, col, proxy_index, source_index, source_model): """ Subclasses should override this function to return the tooltip appropriate for a given cell, if any. """ return None
[docs] def columnCount(self, parent=None): # See Qt documentation for method documentation return len(self._cur_columns)
[docs] def endLayoutChange(self): self._invalidateAllPersistentIndices() self.layoutChanged.emit()
def _sourceRowsAboutToBeInserted(self, source_parent, first, last): # See QAbstractItemModel.rowsAboutToBeInserted signal documentation for # argument documentation proxy_parent = self.mapFromSource(source_parent) self.beginInsertRows(proxy_parent, first, last) def _sourceRowsAboutToBeRemoved(self, source_parent, first, last): # See QAbstractItemModel.rowsAboutToBeRemoved signal documentation for # argument documentation proxy_parent = self.mapFromSource(source_parent) self.beginRemoveRows(proxy_parent, first, last)
[docs] @QtCore.pyqtSlot(int, int) def updateData(self, role, source_row): """ Update a specified index when data changes. :param role: Role of the data that changed. :type role: `enum.Enum` :param source_row: Index of the source model index to update. :type source_row: int """ row = self.mapFromSource(self.index(source_row, 0)).row() col = self.ROLE_TO_COL.get(role) if col is None or row < 0: return index = self.index(row, col) self.dataChanged.emit(index, index)
def _rowHeightChanged(self): """ Emit dataChanged and rowHeightChanged when we receive a rowHeightChanged signal. We emit dataChanged with an invalid index to signal that all indices have changed, to force a cache clear and repaint. """ invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) self.rowHeightChanged.emit() def _textSizeChanged(self): """ Emit dataChanged and textSizeChanged when we receive a textSizeChanged signal. We emit dataChanged with an invalid index to signal that all indices have changed, to force a cache clear and repaint. """ invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) self.textSizeChanged.emit()
[docs] def isWorkspaceAln(self): """ :return: Whether this model represents the workspace alignment. :rtype: bool """ return self.sourceModel().isWorkspaceAln()
[docs] def selectableColumns(self): """ Return a list of all selectable columns (i.e. columns flagged with Qt.ItemIsSelectable). :rtype: list(int) """ return self._selectable_cols.copy()
def _sourceDataChanged(self, topleft, bottomright, roles=None): """ Respond to data changing in the source model. Standard Qt model/view behavior ignores dataChanged signals with invalid indices, but we use them to indicate that everything has changed, so this method passes those signals along to the view. See QAbstractItemModel.dataChaged signal documentation for argument documentation. """ if not topleft.isValid() and not bottomright.isValid(): self.dataChanged.emit(topleft, bottomright)
class AlignmentInfoColumns(table_helper.TableColumns): """ An enum for the left-hand fixed columns. """ Expand = FixedColumn("") Struct = FixedColumn("", selectable=True) Title = FixedColumn("TITLE", tooltip=None, role=CustomRole.RowTitle, all_rows=True, align=Qt.AlignLeft, selectable=True) Chain = FixedColumn("CHN", tooltip="Chain", role=CustomRole.ChainCol, all_rows=True, selectable=True) # NOTE: Title column expands to fill full width of the table, so is # not technically "Fixed"
[docs]class AlignmentInfoProxyModel(DropProxyMixin, MouseOverPassthroughMixin, AnnotationSelectionPassthroughMixin, BaseAdjacentAlignmentProxyModel): """ A proxy model that contains sequence info for an alignment. """ Column = AlignmentInfoColumns ROLES_FROM_SOURCE = (BaseAdjacentAlignmentProxyModel.ROLES_FROM_SOURCE | { CustomRole.Included, CustomRole.CanDropAbove, CustomRole.CanDropBelow, CustomRole.IncludeInDragImage, CustomRole.HomologyStatus, CustomRole.PreviousRowHidden, CustomRole.NextRowHidden, SeqInfo.Title, SeqInfo.GaplessLength, SeqInfo.GapCount, SeqInfo.Chain, SeqInfo.Name, CustomRole.EntryID, CustomRole.PfamName, }) ROLES_FROM_PROXY = ( BaseAdjacentAlignmentProxyModel.ROLES_FROM_PROXY | {Qt.DecorationRole, Qt.ForegroundRole, Qt.BackgroundRole} | {CustomRole.InfoColumnType}) orderChanged = QtCore.pyqtSignal(list)
[docs] def __init__(self, parent=None): super(AlignmentInfoProxyModel, self).__init__(parent=parent) self._bold_font = QtGui.QFont(self._regular_font) self._bold_font.setBold(True) self._mouse_over_struct_col = False self._context_over_struct_col = False # Add role-to-column mappings to ROLE_TO_COL so that # FixedColumnDataChanged signals can be correctly interpreted. These # roles aren't given in AlignmentInfoColumns above since their data are # provided via custom roles or Qt.DecorationRole, and data for roles # given in AlignmentInfoColumns is assumed to be provided via # Qt.DisplayRole. self.ROLE_TO_COL[CustomRole.PreviousRowHidden] = self.Column.Expand self.ROLE_TO_COL[CustomRole.NextRowHidden] = self.Column.Expand self.ROLE_TO_COL[CustomRole.HomologyStatus] = self.Column.Struct self.ROLE_TO_COL[CustomRole.Included] = self.Column.Struct self.ROLE_TO_COL[CustomRole.AnnotationSelected] = self.Column.Title self.setLightMode(False)
[docs] def setMouseOverIndex(self, proxy_index): """ Store whether the mouse is over the struct column and the mouse-over index """ col = proxy_index.column() if proxy_index is not None else None self._mouse_over_struct_col = col == self.Column.Struct if col == self.Column.Expand: # ignore the mouse when it's over the expansion column since we # don't want to highlight the row in that case proxy_index = None super().setMouseOverIndex(proxy_index)
[docs] def setContextOverIndex(self, proxy_index): self._context_over_struct_col = (proxy_index.column() == self.Column.Struct if proxy_index is not None else False) super().setContextOverIndex(proxy_index)
[docs] def isMouseOverStructCol(self): """ Return whether the mouse is over the Struct column """ return self._mouse_over_struct_col or self._context_over_struct_col
[docs] def setLightMode(self, enabled): """ Set light mode for the info model. This changes the structure inclusion/exclusion icons as well as changing text and background color """ if enabled: self._ref_background_brush = QtGui.QBrush( color.REF_SEQ_BACKGROUND_COLOR_LM) self._ref_row_color = color.REF_SEQ_FONT_COLOR_LM self._ref_row_sel_color = color.REF_SEQ_FONT_COLOR_LM self._sel_color = color.REG_FONT_COLOR_LM self._default_color = color.REG_FONT_COLOR_LM else: self._ref_background_brush = QtGui.QBrush( color.REF_SEQ_BACKGROUND_COLOR) self._ref_row_color = color.REF_SEQ_FONT_COLOR self._ref_row_sel_color = color.REF_SEQ_SEL_FONT_COLOR self._sel_color = color.REG_SEL_FONT_COLOR self._default_color = color.REG_FONT_COLOR self._icon_map = self._createIconMap(enabled) invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) # Force repaint
def _createIconMap(self, light_mode): """ Generate icons for the structure column """ # We show an icon to indicate A) whether the sequence # has a structure and B) if it does, whether or not it's included. # No distinction is made for visibility. # See MSV-1590. struct_images = { None: None, True: self.getStructImage(True, light_mode), False: self.getStructImage(False, light_mode), } homology_colors = { None: None, HomologyStatus.Target: color.HOMOLOGY_TARGET_COLOR, HomologyStatus.Template: color.HOMOLOGY_TEMPLATE_COLOR, } transparent = QtGui.QColor(0, 0, 0, 0) painter = QtGui.QPainter() image_size = struct_images[True].size() icon_map = {} prod = itertools.product(homology_colors.items(), struct_images.items()) for (homology_key, fill_color), (struct_key, struct_image) in prod: if fill_color is None and struct_image is None: image = None else: image = QtGui.QImage(image_size, QtGui.QImage.Format_ARGB32) if fill_color is None: fill_color = transparent image.fill(fill_color) if struct_image is not None: painter.begin(image) painter.drawImage(0, 0, struct_image) painter.end() icon_map[(homology_key, struct_key)] = image return icon_map
[docs] def getStructImage(self, included, light_mode): """ Get the image to be in the structure column :param included: Whether or not the structure is included in the workspace :type included: bool :param light_mode: Whether or not to use the light mode icons :type light_mode: bool :return: The structure column image :rtype: QtGui.QImage """ path = ":/msv/icons/struct_" if included: path += "included" else: path += "excluded" if light_mode: path += "_light" path += ".png" return QtGui.QImage(path)
[docs] def setSeqSelectionState(self, selection, selected): """ Mark the residues specified by `selection` as either selected or deselected. :param selection: A selection containing the entries to update. :type selection: QtCore.QItemSelection :param selected: Whether the entries should be selected (True) or deselected (False). :type selected: bool """ source_selection = self.mapSelectionToSource(selection) self.sourceModel().setSeqSelectionState(source_selection, selected)
[docs] def clearSeqSelection(self): self.getAlignment().seq_selection_model.clearSelection()
[docs] def getMinimumWidth(self) -> int: """ Return the minimum width of the view based on the title and chain title lengths. :return: The minimum length of the view """ font_metrics = QtGui.QFontMetrics(self._regular_font) width = font_metrics.horizontalAdvance(self.Column.Title.title + self.Column.Chain.title) return width + 50
[docs] def getTitleColumnWidth(self): """ Return the width of the longest title currently shown, including both sequence titles and annotation names :rtype: int :return: The length of the longest title currently shown """ # sequences may have bold font but annotations do not, so we calculate # their respective widths separately seq_font_metrics = QtGui.QFontMetrics(self._bold_font) ann_font_metrics = QtGui.QFontMetrics(self._regular_font) default_width = seq_font_metrics.horizontalAdvance('W') * 5 source_model = self.sourceModel() if source_model is None: return default_width aln = source_model.getAlignment() if aln is None or len(aln) == 0: return default_width longest_seq = max((seq.name for seq in aln), key=len) seq_title_length = seq_font_metrics.horizontalAdvance(longest_seq) shown_anns = source_model.getShownRowTypes() annotation_names = [ann.title for ann in shown_anns] # If ligand contacts are shown, they are in the row title if SEQ_ANNO_TYPES.binding_sites in shown_anns: for seq in aln: annotation_names.extend(seq.annotations.ligands) longest_ann = max(annotation_names, key=len) if annotation_names else "" ann_title_length = ann_font_metrics.horizontalAdvance(longest_ann) if (source_model.getGroupBy() is GroupBy.Sequence or not annotation_names): return max(default_width, seq_title_length, ann_title_length) else: combined_length = ( ann_font_metrics.horizontalAdvance(longest_seq + " ") + ann_title_length) return max(default_width, seq_title_length, combined_length)
[docs] @QtCore.pyqtSlot(int, int) def updateData(self, role, source_row): """ Update a specified index when data changes. :param role: Role of the data that changed. :type role: `enum.Enum` :param source_row: Index of the source model index to update. :type source_row: int """ if role == CustomRole.MouseOver: row = self.mapFromSource(self.index(source_row, 0)).row() if row < 0: return start_col = self._cur_columns[0] end_col = self._cur_columns[-1] start_index = self.index(row, start_col) self.dataChanged.emit(start_index, self.index(row, end_col)) num_children = self.rowCount(parent=start_index) if num_children: child_start_index = self.index(0, start_col, start_index) child_end_index = self.index(num_children - 1, end_col, start_index) self.dataChanged.emit(child_start_index, child_end_index) else: super().updateData(role, source_row)
def _fetchProxyRowData(self, roles, source_data, cols, is_title_row, display_data_requested, font_requested, decoration_requested, foreground_requested, background_requested, tooltip_requested): """ See parent class for method documentation. Note that this method takes several additional arguments relative to the parent class method, which are documented here. (These additional arguments match the additional return values from `_mapRolesToSource`.) :param font_requested: Whether Qt.FontRole data was requested. This role will not be in `roles` since `self._fontData` calls `self._multipleRolesData`, which calls this method, so fetching this value through the normal mechanisms would lead to an infinite loop. :type font_requested: bool :param decoration_requested: Whether Qt.DecorationRole data was requested. This role will not be in `roles` since `self._decorationData` calls `self._multipleRolesData`, which calls this method, so fetching this value through the normal mechanisms would lead to an infinite loop. :type decoration_requested: bool :param foreground_requested: Whether Qt.ForegroundRole data was requested. :type foreground_requested: bool :param background_requested: Whether Qt.BackgroundRole data was requested. :type background_requested: bool :param tooltip_requested: Whether Qt.ToolTipRole data was requested. :type tooltip_requested: bool """ row_data = super()._fetchProxyRowData(roles, source_data, cols, is_title_row, display_data_requested) for cur_col_num, cell_data in zip(cols, row_data): if font_requested: self._fontDataFromSourceData(cell_data) if decoration_requested: self._decorationDataFromSourceData(cell_data, cur_col_num) if foreground_requested: self._foregroundDataFromSourceData(cell_data) if background_requested: self._backgroundDataFromSourceData(cell_data) if tooltip_requested: self._toolTipDataFromSourceData(cell_data) return row_data def _mapRolesToSource(self, is_title_row, cols, roles): """ See parent class for method documentation. Note that the return value here includes several additional values not included in the parent class method's return value. :return: A tuple of: - Roles to fetch from the source model - Roles of data that can be calculated in this proxy - Whether Qt.DisplayRole data was requested - Whether Qt.FontRole data was requested - Whether Qt.DecorationRole data was requested - Whether Qt.ForegroundRole data was requested - Whether Qt.BackgroundRole data was requested - Whether Qt.ToolTipRole data was requested :rtype: tuple(set(int), set(int), bool, bool, bool, bool, bool) """ font_requested = Qt.FontRole in roles foreground_requested = Qt.ForegroundRole in roles background_requested = Qt.BackgroundRole in roles tooltip_requested = Qt.ToolTipRole in roles roles -= { Qt.FontRole, Qt.ForegroundRole, Qt.BackgroundRole, Qt.ToolTipRole } if font_requested or foreground_requested or background_requested: roles.add(CustomRole.ReferenceSequence) roles.add(CustomRole.RowType) decoration_requested = Qt.DecorationRole in roles if decoration_requested: roles.discard(Qt.DecorationRole) if self.Column.Struct in cols: roles.add(CustomRole.HomologyStatus) roles.add(CustomRole.Included) roles.add(CustomRole.RowType) if tooltip_requested: roles.add(SeqInfo.Title) roles.add(CustomRole.RowType) roles.add(SeqInfo.GaplessLength) roles.add(SeqInfo.GapCount) roles.add(SeqInfo.Chain) roles.add(SeqInfo.Name) roles.add(CustomRole.EntryID) roles.add(CustomRole.PfamName) roles, proxy_roles, display_data_requested = super()._mapRolesToSource( is_title_row, cols, roles) return (roles, proxy_roles, display_data_requested, font_requested, decoration_requested, foreground_requested, background_requested, tooltip_requested) def _fontDataFromSourceData(self, data): reference_row = data.get(CustomRole.ReferenceSequence) sequence_row = data.get(CustomRole.RowType) is RowType.Sequence if reference_row and sequence_row: font = self._bold_font else: font = self._regular_font data[Qt.FontRole] = font def _decorationDataFromSourceData(self, data, col): if data.get(CustomRole.RowType) is not RowType.Sequence: return if col == self.Column.Struct: homology_status = data.get(CustomRole.HomologyStatus) included = data.get(CustomRole.Included) is_included = included_map.get(included) icon = self._icon_map[(homology_status, is_included)] data[Qt.DecorationRole] = icon def _foregroundDataFromSourceData(self, data): """ Get the foreground role - text color - for the given cell data. """ reference_row = data.get(CustomRole.ReferenceSequence) sequence_row = data.get(CustomRole.RowType) is RowType.Sequence selected_row = data.get(CustomRole.SeqSelected) if reference_row and sequence_row: if selected_row: font_color = self._ref_row_sel_color else: font_color = self._ref_row_color elif selected_row: font_color = self._sel_color else: font_color = self._default_color data[Qt.ForegroundRole] = font_color def _backgroundDataFromSourceData(self, data): """ Get the background color brush for the given cell data for reference sequence rows only. """ reference_row = data.get(CustomRole.ReferenceSequence) sequence_row = data.get(CustomRole.RowType) is RowType.Sequence if reference_row and sequence_row: data[Qt.BackgroundRole] = self._ref_background_brush def _toolTipDataFromSourceData(self, data): """ Get the tooltip text for the given cell data """ row_type = data.get(CustomRole.RowType) tooltip_list = [data.get(SeqInfo.Name)] if row_type is RowType.Sequence: tooltip_list.extend(self._getToolTipSequenceData(data)) elif row_type is SEQ_ANNO_TYPES.pfam: tooltip_list = [data.get(CustomRole.PfamName)] elif row_type in SEQ_ANNO_TYPES or row_type in ALN_ANNO_TYPES: tooltip_list.extend(self._getToolTipAnnotationData(data)) if None in tooltip_list: return tooltip = '<br>'.join(tooltip_list) data[Qt.ToolTipRole] = tooltip def _getToolTipSequenceData(self, data) -> typing.List[str]: """ Get a list of tooltip data to show for a sequence. """ tooltip = [] chain_name = data.get(SeqInfo.Chain) if chain_name: tooltip.append(f'Chain {chain_name}') seq_title = data.get(SeqInfo.Title) if seq_title: if len(seq_title) > 80: tooltip.append(f'{seq_title}...') else: tooltip.append(seq_title) res_count = data.get(SeqInfo.GaplessLength) res_str = inflect.engine().no('residue', res_count) gap_count = data.get(SeqInfo.GapCount) full_gap_str = '' if gap_count > 0: gap_str = inflect.engine().no('gap', gap_count) full_gap_str = f', {gap_str}' tooltip.append(f'{res_str}{full_gap_str}') entry_id = data.get(CustomRole.EntryID) if entry_id: tooltip.append(f'Entry ID {entry_id}') return tooltip def _getToolTipAnnotationData(self, data) -> typing.List[str]: """ Get a list of tooltip data to show for a sequence or alignment annotation. """ tooltip = [] chain_name = data.get(SeqInfo.Chain) row_type = data.get(CustomRole.RowType) if row_type in PRED_ANNO_TYPES: tooltip.append('<i>Prediction</i>') elif chain_name: tooltip.append(f'Chain {chain_name}') if row_type in RES_PROP_ANNO_TYPES or not row_type.tooltip: tooltip.append(row_type.title) else: tooltip.append(row_type.tooltip) return tooltip @table_helper.data_method(Qt.FontRole) def _fontData(self, col, is_title_row, proxy_index, source_index, source_model): multiple_roles = {Qt.FontRole} data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, multiple_roles) return data.get(Qt.FontRole) @table_helper.data_method(Qt.DecorationRole) def _decorationData(self, col, is_title_row, proxy_index, source_index, source_model): data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, {Qt.DecorationRole}) return data.get(Qt.DecorationRole) @table_helper.data_method(Qt.ForegroundRole) def _foregroundData(self, col, is_title_row, proxy_index, source_index, source_model): multiple_roles = {Qt.ForegroundRole} data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, multiple_roles) return data.get(Qt.ForegroundRole) @table_helper.data_method(Qt.BackgroundRole) def _backgroundData(self, col, is_title_row, proxy_index, source_index, source_model): multiple_roles = {Qt.BackgroundRole} data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, multiple_roles) return data.get(Qt.BackgroundRole) def _nonHeaderToolTipData(self, col, proxy_index, source_index, source_model): """ Defines tooltip data for non-header rows of the table model. :param col: Column for this row :type col: int :param proxy_index: Proxy index for the cell :type proxy_index: `QtCore.QModelIndex` :param source_index: Source index for this cell :type source_index: `QtCore.QModelIndex` :param source_model: Source model for the table :type source_model: `QtWidgets.QAbstractItemModel` """ multiple_roles = {Qt.ToolTipRole} data = self._multipleRolesData(col, False, proxy_index, source_index, source_model, multiple_roles) return data.get(Qt.ToolTipRole)
[docs] def getAlignment(self): """ Return the underlying alignment :return: The alignment if the source model exists; else None :rtype: `schrodinger.protein.alignment.BaseAlignment` """ source_model = self.sourceModel() if source_model is not None: return source_model.getAlignment()
[docs] def flags(self, index): # See Qt documentation for additional method documentation flags = Qt.ItemIsEnabled if index.column() in self._selectable_cols: data = self.data(index, CustomRole.MultipleRoles, {CustomRole.RowType, CustomRole.ReferenceSequence}) row_type = data[CustomRole.RowType] if row_type is RowType.Sequence: flags |= Qt.ItemIsSelectable if not data[CustomRole.ReferenceSequence]: flags |= Qt.ItemIsDragEnabled elif row_type in SELECTABLE_ANNO_TYPES: flags |= Qt.ItemIsSelectable return flags
[docs] def mimeData(self, indices): """ Return an object used to represent `indices` during a drag-and-drop operation. Since we only allow selected indices to be dragged, and since the selection is stored with the model rather than the view, we can safely ignore `indices` and just return an empty object. The selected sequences are then fetched during the drop instead. See QAbstractItemModel documentation for additional method documentation. """ return QtCore.QMimeData()
@table_helper.data_method(CustomRole.InfoColumnType) def _infoColumnTypeData(self, col): return col
class AlignmentMetricsColumns(table_helper.TableColumns): """ An enum for the right-hand fixed columns. """ Identity = FixedColumn("ID %", "Identity", CustomRole.AlignmentIdentity, percent=True, align=Qt.AlignRight) Similarity = FixedColumn("SIM %", "Similarity", CustomRole.AlignmentSimilarity, percent=True, align=Qt.AlignRight) Conservation = FixedColumn("CON %", "Conservation", CustomRole.AlignmentConservation, percent=True, align=Qt.AlignRight) Score = FixedColumn("SCORE", "Score", CustomRole.AlignmentScore, percent=False, align=Qt.AlignRight)
[docs]class AlignmentMetricsProxyModel(BaseAdjacentAlignmentProxyModel): """ A proxy model that contains the alignment metrics, such as percentage of identity, similarity, homology and score to the reference sequence. Columns can be enabled or disabled via the options model. :note: If all columns are disabled, then this model will be completely empty (i.e. zero rows in addition to zero columns). That's because there's no way to generate a valid QModelIndex object without any columns, so there would be no way to create a parent index when referring to a nested row. Instead, this model will ignore all row changes until a column is enabled, at which point it will emit modelAboutToBeReset and modelReset to resync the view. """ Column = AlignmentMetricsColumns ROLES_FROM_SOURCE = BaseAdjacentAlignmentProxyModel.ROLES_FROM_SOURCE | { *range(RoleBase.SequenceProperty, RoleBase.SequenceProperty + MAX_SEQ_PROPS) } ROLES_FROM_PROXY = BaseAdjacentAlignmentProxyModel.ROLES_FROM_PROXY | { Qt.ForegroundRole, CustomRole.MetricsType }
[docs] def __init__(self, parent): """ :param parent: The Qt parent widget :type parent: QtWidgets.QWidget """ super().__init__(parent) self.options_model = None self._cur_columns = [] self.setLightMode(False)
[docs] def setLightMode(self, enabled): """ Set light mode for the alignment metrics table. This changes the font color that is used """ if enabled: self.font_color = color.REG_FONT_COLOR_LM else: self.font_color = color.REG_FONT_COLOR invalid_index = QtCore.QModelIndex() self.dataChanged.emit(invalid_index, invalid_index) # Force repaint
def _columnSettings(self): """ Get information on what columns are currently enabled in the options model. :return: A tuple of tuples of (column, enabled) :rtype: tuple(tuple(AlignmentMetricsColumn, bool)) """ c = self.Column options = self.options_model cols = [ (c.Identity, options.show_identity_col), (c.Similarity, options.show_similarity_col), (c.Conservation, options.show_conservation_col), (c.Score, options.show_score_col) ] # yapf: disable for idx, item in enumerate(options.sequence_properties): role = RoleBase.SequenceProperty + idx col = SequencePropertyColumn(item, role, title=item.display_name, tooltip=item.display_name) cols.append((col, item.visible)) return cols # yapf: disable
[docs] def setOptionsModel(self, options_model): """ Set the options model for the proxy, which are used for row filtering. :param options_model: The widget options. :type options_model: `schrodinger.application.msv.gui.gui_models. OptionsModel` """ attrs = ('show_score_col', 'show_similarity_col', 'show_conservation_col', 'show_identity_col', 'sequence_properties') if self.options_model is not None: for attr_name in attrs: signal = getattr(self.options_model, f"{attr_name}Changed") signal.disconnect() for attr_name in attrs: signal = getattr(options_model, f"{attr_name}Changed") signal.connect(self._invalidateFilter) self.options_model = options_model self._cur_columns = [ col for col, enabled in self._columnSettings() if enabled ]
[docs] def rowCount(self, parent=None): # See Qt documentation for method documentation if not self._cur_columns: return 0 else: return super(AlignmentMetricsProxyModel, self).rowCount(parent)
def _invalidateFilter(self): """ Update which columns are included and excluded, emitting any signals necessary to notify the view of the changes. """ self.beginResetModel() self._cur_columns = [ col for col, enabled in self._columnSettings() if enabled ] self.endResetModel() def _sourceRowsAboutToBeInserted(self, source_parent, first, last): # See QAbstractItemModel.rowsAboutToBeInserted signal documentation for # argument documentation if self._cur_columns: super()._sourceRowsAboutToBeInserted(source_parent, first, last) def _sourceRowsAboutToBeRemoved(self, source_parent, first, last): # See QAbstractItemModel.rowsAboutToBeRemoved signal documentation for # argument documentation if self._cur_columns: super()._sourceRowsAboutToBeRemoved(source_parent, first, last)
[docs] def endInsertRows(self): # See QAbstractItemModel documentation for method documentation if self._cur_columns: super().endInsertRows()
[docs] def endRemoveRows(self): # See QAbstractItemModel documentation for method documentation if self._cur_columns: super().endRemoveRows()
[docs] def flags(self, index): # See QAbstractItemModel documentation for method documentation # We assume that no columns in AlignmentMetricsColumns are defined as # selectable. return Qt.ItemIsEnabled
@table_helper.data_method(CustomRole.MetricsType) def _metricsTypeData(self, col): return col def _fetchProxyRowData(self, roles, source_data, cols, is_title_row, display_data_requested, foreground_requested): """ See parent class for method documentation. Note that this method takes several additional arguments relative to the parent class method, which are documented here. (These additional arguments match the additional return values from `_mapRolesToSource`.) :param foreground_requested: Whether Qt.ForegroundRole data was requested. :type foreground_requested: bool """ row_data = super()._fetchProxyRowData(roles, source_data, cols, is_title_row, display_data_requested) if foreground_requested: for cell_data in row_data: self._foregroundDataFromSourceData(cell_data) return row_data def _mapRolesToSource(self, is_title_row, cols, roles): """ See parent class for method documentation. Note that the return value here includes several additional values not included in the parent class method's return value. :return: A tuple of: - Roles to fetch from the source model - Roles of data that can be calculated in this proxy - Whether Qt.DisplayRole data was requested - Whether Qt.ForegroundRole data was requested :rtype: tuple(set(int), set(int), bool, bool) """ foreground_requested = Qt.ForegroundRole in roles roles.discard(Qt.ForegroundRole) roles, proxy_roles, display_data_requested = super()._mapRolesToSource( is_title_row, cols, roles) return (roles, proxy_roles, display_data_requested, foreground_requested) def _foregroundDataFromSourceData(self, data): """ Get the foreground role - text color - for the given cell data. """ data[Qt.ForegroundRole] = self.font_color @table_helper.data_method(Qt.ForegroundRole) def _foregroundData(self, col, is_title_row, proxy_index, source_index, source_model): multiple_roles = {Qt.ForegroundRole} data = self._multipleRolesData(col, is_title_row, proxy_index, source_index, source_model, multiple_roles) return data.get(Qt.ForegroundRole) def _nonHeaderToolTipData(self, col, proxy_index, source_index, source_model): metric = source_model.data(source_index, col.role) if metric is None: return None if col.percent: return f'{100*metric:.2f}%' try: return f'{float(metric):.4f}' except ValueError: return metric