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
@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
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