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