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

import enum
import weakref

import inflect

from schrodinger.application.msv.gui import dendrogram_viewer_ui
from schrodinger.application.msv.gui import dialogs
from schrodinger.application.msv.gui import msv_widget
from schrodinger.application.msv.gui import popups
from schrodinger.application.msv.gui import stylesheets
from schrodinger.infra import util
from schrodinger.models import mappers
from schrodinger.models import parameters
from schrodinger.protein.tasks import clustal
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui import dendrogram as dendro
from schrodinger.ui.dendrogram import DendrogramView
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import mapperwidgets
from schrodinger.ui.qt import pop_up_widgets
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.dendrogram import Dendrogram
from schrodinger.ui.qt.utils import suppress_signals
from schrodinger.ui.qt.widgetmixins import basicmixins
from schrodinger.ui.qt.widgetmixins import panelmixins

from . import dendrogram_adv_options_ui

RECALCULATE_MSG = """
Some computation settings were changed, but
the tree was not recalculated. Do you want to
recalculate the tree now?

Choose OK to recalculate, Cancel to discard
the changes.
"""

DENDROGRAM_VIEW_FACTOR = 12


[docs]class TREE_LAYOUT(enum.Enum): RECTANGULAR = 'Rectangular' RADIAL = 'Radial'
[docs]class SIMILARITY_MATRIX(enum.Enum): BLOSUM = 'BLOSUM' PAM = 'PAM' GONNET = 'GONNET'
[docs]class RELATIONSHIP(enum.Enum): IDENTITY = 'Identity %' SIMILARITY = 'Similarity %'
TREE_TYPE = enum.Enum('TREE_TYPE', 'NJ UPGMA')
[docs]class AdvancedOptionsModel(parameters.CompoundParam): similarity_matrix: SIMILARITY_MATRIX tree_type: TREE_TYPE relationship: RELATIONSHIP
[docs]class MSVDendrogramView(DendrogramView): """ Dendrogram view class customized for MSV. :ivar storeSelectionRequested: Signal emitted to store the existing selection. :ivar restoreSelectionRequested: Signal emitted to restore the previously stored selection. :ivar sceneContextMenuRequested: Signal emitted to request a right click menu in the scene. """ storeSelectionRequested = QtCore.pyqtSignal() restoreSelectionRequested = QtCore.pyqtSignal() sceneContextMenuRequested = QtCore.pyqtSignal(QtCore.QPoint)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setContextMenuPolicy(Qt.CustomContextMenu) self.setDragMode(self.RubberBandDrag) self._right_click_held = False self._mouse_drag_pos = None self._origin_drag_pos = None
[docs] def wheelEvent(self, event: QtGui.QWheelEvent): """ Scale view on scroll. """ factor = 1.1 if event.angleDelta().y() < 0: factor = 1 / factor self.setTransformationAnchor(self.AnchorUnderMouse) self.scale(factor, factor)
[docs] def mousePressEvent(self, event: QtGui.QMouseEvent): """ Update the mouse press event to enable right click dragging. """ if event.button() == Qt.RightButton: self._right_click_held = True self._mouse_drag_pos = event.pos() self._origin_drag_pos = event.pos() self.setCursor(Qt.ClosedHandCursor) self.storeSelectionRequested.emit() super().mousePressEvent(event) self.restoreSelectionRequested.emit()
[docs] def mouseReleaseEvent(self, event: QtGui.QMouseEvent): """ Update the mouse release event to finish right click dragging. """ if event.button() == Qt.RightButton: self._right_click_held = False self.setCursor(Qt.ArrowCursor) if self._origin_drag_pos == event.pos(): self.sceneContextMenuRequested.emit(event.pos()) self._mouse_drag_pos = None super().mouseReleaseEvent(event)
[docs] def mouseMoveEvent(self, event: QtGui.QMouseEvent): """ Move the view's scrollbars to where the cursor is dragged. """ if self._right_click_held: hori_scroll_bar = self.horizontalScrollBar() vert_scroll_bar = self.verticalScrollBar() hori_scroll_bar.setValue(hori_scroll_bar.value() - (event.x() - self._mouse_drag_pos.x())) vert_scroll_bar.setValue(vert_scroll_bar.value() - (event.y() - self._mouse_drag_pos.y())) self._mouse_drag_pos = event.pos() super().mouseMoveEvent(event)
[docs]class AdvancedOptionPopUp(panelmixins.CleanStateMixin, mappers.MapperMixin, widgetmixins.InitMixin, basicmixins.MessageBoxMixin, pop_up_widgets.PopUp): """ Advanced options pop up for dendrogram viewer. :ivar recalculationRequested: Signal emitted when 'Recalculate Tree' button is clicked. :vartype recalculationRequested: QtCore.pyqtSignal """ model_class = AdvancedOptionsModel ui_module = dendrogram_adv_options_ui recalculationRequested = QtCore.pyqtSignal()
[docs] def setup(self): pass
[docs] def initSetUp(self): super().initSetUp() self.ui.tree_layout_combo.setEnum(TREE_LAYOUT) self.ui.relationship_metric_combo.setEnum(RELATIONSHIP) self.ui.similarity_matrix_combo.setEnum(SIMILARITY_MATRIX) self.tree_type_btn_grp = mapperwidgets.MappableButtonGroup({ self.ui.nj_rb: TREE_TYPE.NJ, self.ui.average_distance_rb: TREE_TYPE.UPGMA }) self.ui.recalculate_tree_btn.clicked.connect( self.recalculationRequested.emit) self.ui.reset_btn.clicked.connect(self.initSetDefaults) self.ui.close_btn.clicked.connect(self.close) self.ui.recalculate_tree_btn.setEnabled(False) self.close_btn_clicked = None self.recalculationRequested.connect(self.saveCleanState)
[docs] def defineMappings(self): M = self.model_class return super().defineMappings() + [ (self.ui.relationship_metric_combo, M.relationship), (self.ui.similarity_matrix_combo, M.similarity_matrix), (self.tree_type_btn_grp, M.tree_type), (self._onRelationshipChanged, M.relationship), (self._updateResetBtn, M), (self._updateRecalculateTreeBtn, M), ]
def _onRelationshipChanged(self): """ Enable/Disable the 'Similarity Matrix' combobox depending on the selected relationship metric. """ similarity = self.model.relationship == RELATIONSHIP.SIMILARITY self.ui.similarity_matrix_combo.setEnabled(similarity) def _updateResetBtn(self): """ Enable the reset button only when any GUI options has non-default value. """ self.ui.reset_btn.setEnabled(not self.model.isDefault()) def _updateRecalculateTreeBtn(self): """ Enable 'Recalculate Tree' button only when there is change in the options from the previously saved state. """ if self._clean_state is not None: enable = self._clean_state != self.model self.ui.recalculate_tree_btn.setEnabled(enable)
[docs] def saveCleanState(self): self.ui.recalculate_tree_btn.setEnabled(False) super().saveCleanState()
[docs] def close(self): self.discardChanges() self.close_btn_clicked = True super().close()
[docs] def hideEvent(self, event): # Show a question dialog to confirm whether to apply the changes or not if not self.close_btn_clicked and \ self.ui.recalculate_tree_btn.isEnabled(): ok = self.question(RECALCULATE_MSG, title='Changes Not Applied') if ok: self.recalculationRequested.emit() else: self.discardChanges() self.close_btn_clicked = False super().hideEvent(event)
def _get_alignment_string(aln): seq_strs = [] for seq in aln.getShownSeqs(): seq_strs.append(str(seq).replace(seq.gap_char, "")) return "\n".join(seq_strs)
[docs]class DendrogramViewer(basicmixins.StatusBarMixin, basewidgets.BaseWidget): ui_module = dendrogram_viewer_ui _updatingSelection = util.flag_context_manager('_updating_selection')
[docs] def initSetOptions(self): super().initSetOptions() self.help_topic = "MSV_DENDROGRAM_VIEWER" self.setWindowTitle('Sequence Dendrogram')
[docs] def initSetUp(self): super().initSetUp() self._tree_aln_str = '' self._updating_selection = False self._stored_selection = None self._dendrogram_name_to_seq = None self._dendrogram_seq_to_name = None self._aln = None self._selected_seqs_lbl = QtWidgets.QLabel() self.dendrogram = Dendrogram(ViewCls=MSVDendrogramView) self._setTreeIsStale(stale_state=False) self.save_image_btn = QtWidgets.QToolButton() self.save_image_btn.setText("Save Image...") self.save_image_btn.clicked.connect(self.saveDendrogram) self._selected_seqs_lbl.setObjectName("selected_lbl") self.setStyleSheet(stylesheets.DENDROGRAM_VIEWER_STYLESHEET) self.ui.reload_btn.clicked.connect(self._reloadDendrogram) adv_opts_btn = popups.make_pop_up_tool_button( parent=self, pop_up_class=AdvancedOptionPopUp, obj_name='gear_btn', ) adv_opts_btn.setPopupValign(adv_opts_btn.ALIGN_BOTTOM) self.ui.gear_button_hlayout.addWidget(adv_opts_btn) self.adv_opts_dlg = adv_opts_btn.popup_dialog self.adv_opts_dlg.recalculationRequested.connect(self._recalculate) self.adv_opts_dlg.ui.tree_layout_combo.currentIndexChanged.connect( self._reloadDisplay) # TODO MSV-3480: Once hover effect is implemented, delete this line # (hover instructions are set in UI file) self.ui.instruction_lbl.setText( "Choose branches to select the sequences in the tab.") # TODO MSV-3573: Redisplay the widgets. self.ui.color_cb.setVisible(False) self.ui.smilarity_percent_rb.setVisible(False) self.ui.percent_lbl.setVisible(False) self._updateStatusLabel()
[docs] def initLayOut(self): super().initLayOut() self.status_bar.addWidget(self.save_image_btn) # For some reason, `insertPermanentWidget` doesn't work here. self.status_bar.removeWidget(self.help_btn) self.status_bar.addPermanentWidget(self._selected_seqs_lbl) self.status_bar.addPermanentWidget(self.help_btn) self.help_btn.show()
[docs] def isVisible(self): return self.dendrogram.isVisible()
[docs] def isTreeStale(self): return self._tree_is_stale
def _initializeLabels(self, tree, node): """ Internal method to initialize labels on all nodes. """ label = node.getNode().getLabel() if node.isLeaf(): node.setToolTip(label) graphic_label = node.getLabel() graphic_label.setToolTip(label) if not label: label = str(len(node.getLeafIndices())) node.setLabel(label)
[docs] def setSelection(self, sequences): """ Select the nodes corresponding to `sequences`. :param sequences: an iterable of sequences :type sequences: Iterable[sequences.ProteinSequence] """ labels = set(self._dendrogram_seq_to_name[seq] for seq in sequences) with suppress_signals(self): for node in self.dendrogram.nodes(): label = node.getNode().getLabel() node.setSelected(label in labels) self._onTreeSelectionChanged()
[docs] def getSelection(self): """ :rtype: generator :return: A generator of sequences corresponding to the nodes selected in the dendrogram """ for node in self.dendrogram.m_graphicTree.getSelectedNodes(): label = node.getNode().getLabel() if label and label in self._dendrogram_name_to_seq: yield self._dendrogram_name_to_seq[label]
[docs] def storeSelection(self): """ Store the existing selection. """ self._stored_selection = set(self.getSelection())
[docs] def restoreSelection(self): """ Set the previous selection and clear the stored data. """ if self._stored_selection: self.setSelection(self._stored_selection) self._stored_selection = None
@util.skip_if("_updating_selection") def _onTreeSelectionChanged(self): """ Respond to change in selection and emit set of labels for currently selected nodes """ with self._updatingSelection(): selection = set(self.getSelection()) inflect_engine = inflect.engine() status_txt = "" if len(selection) >= 1: sequences = inflect_engine.no('sequence', len(selection)) status_txt = f"{sequences} selected" self._selected_seqs_lbl.setText(status_txt) self._aln.seq_selection_model.clearSelection() self._aln.seq_selection_model.setSelectionState(selection, True) @util.skip_if("_tree_is_stale") @util.skip_if("_updating_selection") def _onAlignmentSelectionChanged(self): with self._updatingSelection(): self.setSelection(self._aln.seq_selection_model.getSelection()) @QtCore.pyqtSlot() def _setTreeIsStale(self, *, stale_state=True): self._tree_is_stale = stale_state self.ui.reload_btn.setEnabled(stale_state) text = 'Diagram out of date' if stale_state else '' self.status_lbl.setText(text) self._setDendrogramEnabled(not stale_state) def _setDendrogramEnabled(self, enable): # The dendrogram doesn't create the `m_view` until after the first # tree is created. Return early in that case. if not hasattr(self.dendrogram, 'm_view'): return color = QtCore.Qt.white if enable else QtCore.Qt.gray self.dendrogram.m_view.setEnabled(enable) self.dendrogram.m_view.setBackgroundBrush(QtGui.QBrush(color))
[docs] def setAlignment(self, aln): if self._aln is not None: for signal in self._getTreeStalingAlignmentSignals(self._aln): signal.disconnect(self._setTreeIsStale) self._aln.seq_selection_model.selectionChanged.disconnect( self._onAlignmentSelectionChanged) self._aln = aln for signal in self._getTreeStalingAlignmentSignals(aln): signal.connect(self._setTreeIsStale) self._aln.seq_selection_model.selectionChanged.connect( self._onAlignmentSelectionChanged) should_stale = self._tree_aln_str != _get_alignment_string(self._aln) self._setTreeIsStale(stale_state=should_stale)
def _getTreeStalingAlignmentSignals(self, aln): return [ aln.signals.sequencesInserted, aln.signals.sequencesRemoved, aln.signals.residuesRemoved, aln.signals.residuesAdded, ] def _recalculate(self): """ Forcefully reload the dendrogram and update the status label with the calculated relationship. """ self._reloadDendrogram(force=True) self._updateStatusLabel() def _reloadDisplay(self): """ Reload the dendrogram display without running clustal job. """ self._reloadDendrogram(recompute=False, force=True) def _reloadDendrogram(self, *, recompute=True, force=False): """ Reload the dendrogram. The reload is skipped if the alignment was unchanged since last time we calculated the dendrogram. Pass in `force=True` to reload anyways. Pass in 'recompute' to update the dendrogram after re-running clustal job, otherwise just update the display without running the job. """ if (self._tree_aln_str == _get_alignment_string(self._aln) and not force): # The currently displayed dendrogram was already computed using # this alignment. return self._tree_aln_str = _get_alignment_string(self._aln) if len(self._aln.getShownSeqs()) < 2: self._clearScene() self._setTreeIsStale(stale_state=False) return if recompute: job = self._runClustal() dnd_string = job.dnd_string name_to_seq = weakref.WeakValueDictionary() seq_to_name = weakref.WeakKeyDictionary() for name, seq in job.name_seq_mapping.items(): name_to_seq[name] = seq seq_to_name[seq] = name self._dendrogram_name_to_seq = name_to_seq self._dendrogram_seq_to_name = seq_to_name self._setTreeIsStale(stale_state=False) self.dendrogram.loadTreeFromNewick(dnd_string.replace('\n', '')) pref = dendro.DendrogramGraphicTreeCoordinatesPreferences() pref.rectangular = self.adv_opts_dlg.ui.tree_layout_combo.currentText( ) == TREE_LAYOUT.RECTANGULAR.value self.dendrogram.showTree(self._initializeLabels, pref) self.dendrogram.m_graphicTree.selectionChanged.connect( self._onTreeSelectionChanged) old_view = self.ui.dendrogram_layout.takeAt( 0) # Replace old view and replace with new one if old_view: old_view.widget().deleteLater() self.dendrogram.m_view.setStyleSheet("border: 1px solid gray") self.ui.dendrogram_layout.addWidget(self.dendrogram.m_view) self._connectDendrogramView() def _clearScene(self): # The dendrogram doesn't create the `m_view` until after the first # tree is created. Return early in that case. if hasattr(self.dendrogram, 'm_view'): self.dendrogram.m_view.scene().clear() def _runClustal(self): if self.adv_opts_dlg.model.relationship == RELATIONSHIP.IDENTITY: matrix = 'ID' else: matrix = self.adv_opts_dlg.model.similarity_matrix.name clustering = self.adv_opts_dlg.model.tree_type.name job = clustal.ClustalJob(self._aln.getShownSeqs(), matrix=matrix, clustering=clustering) # Open a progress bar using number of output lines from clustal as a # measure of progress. # Estimate the total number of lines using the number of possible # pairwise alignments plus some. with dialogs.aln_progress_dialog(job, 'Creating dendrogram...', parent=self): job.run() return job
[docs] def show(self, aln): """ Displays a dendrogram view and a tree settings dialog. """ self.setAlignment(aln) self._reloadDendrogram() super().show() self.resetView()
def _connectDendrogramView(self): """ Connects dendrogram view signals to viewer methods. """ self.dendrogram.m_view.sceneContextMenuRequested.connect( self.sceneContextMenu) self.dendrogram.m_view.storeSelectionRequested.connect( self.storeSelection) self.dendrogram.m_view.restoreSelectionRequested.connect( self.restoreSelection) def _getDendrogramSceneRects(self, node_subset=None ) -> (QtCore.QRectF, QtCore.QRectF): """ Calculate the fitting rectangle positions of the dendrogram and the scene. :return: the dendrogram rectangle position, the scene rectangle position """ view_rect = self.dendrogram.m_view.sceneRect() if node_subset is None: all_nodes = self.dendrogram.m_graphicTree.listAllNodes() else: all_nodes = node_subset bottom_right = view_rect.bottomRight() top_left = view_rect.topLeft() # get the bounding dendrogram coordinates min_x = bottom_right.x() min_y = bottom_right.y() max_x = top_left.x() max_y = top_left.y() for node in all_nodes: coords = node.getCoordinates() min_x = min(min_x, coords.x()) min_y = min(min_y, coords.y()) max_x = max(max_x, coords.x()) max_y = max(max_y, coords.y()) width = max_x - min_x height = max_y - min_y # if the dendrogram is too large, resize the view with padding view_rect = QtCore.QRectF( QtCore.QPointF( min(top_left.x(), min_x - width / DENDROGRAM_VIEW_FACTOR), min(top_left.y(), min_y - height / DENDROGRAM_VIEW_FACTOR)), QtCore.QPointF( max(bottom_right.x(), max_x + width / DENDROGRAM_VIEW_FACTOR), max(bottom_right.y(), max_y + height / DENDROGRAM_VIEW_FACTOR))) width_pad = (view_rect.width() - width) / DENDROGRAM_VIEW_FACTOR height_pad = (view_rect.height() - height) / DENDROGRAM_VIEW_FACTOR dendro_rect = QtCore.QRectF(min_x - width_pad, min_y - height_pad, width + width_pad * 2, height + height_pad * 2) return dendro_rect, view_rect
[docs] def sceneContextMenu(self, point): """ Display a custom context menu on the scene. """ menu = QtWidgets.QMenu(parent=self) zoom_action = menu.addAction('Zoom to Selected') reset_action = menu.addAction('Reset View') zoom_action.triggered.connect(self.zoomToSelected) reset_action.triggered.connect(self.resetView) menu.exec(self.dendrogram.m_view.mapToGlobal(point))
[docs] def zoomToSelected(self): """ Zoom to the selected features. """ selected_nodes = self.dendrogram.m_graphicTree.getSelectedNodes() if not selected_nodes: return dendro_rect, view_rect = self._getDendrogramSceneRects( node_subset=selected_nodes) self.dendrogram.m_view.setSceneRect(view_rect) self.dendrogram.m_view.fitInView(dendro_rect, QtCore.Qt.KeepAspectRatio)
[docs] def resetView(self): """ Reset the scene view to the default state. """ dendro_rect, view_rect = self._getDendrogramSceneRects() self.dendrogram.m_view.setSceneRect(view_rect) self.dendrogram.m_view.fitInView(dendro_rect, QtCore.Qt.KeepAspectRatio)
[docs] def saveDendrogram(self): """ Show up the file dialog and save the dendrogram to image/pdf. """ dlg = dialogs.MSVImageExportDialog() # Only resolution options are needed here. dlg.export_options_combo.setVisible(False) dlg.include_lbl.setVisible(False) if not dlg.exec(): return filename = dlg.selectedFile() if not filename.strip(): return renderer = msv_widget.get_svg_renderer(self.dendrogram.m_view) if dlg.model.format == dlg.model.format.PNG: dpi = dlg.model.dpi msv_widget.save_png(renderer, filename, dpi) else: msv_widget.save_pdf(renderer, filename)
def _updateStatusLabel(self): """ Update the display text of status_lbl with the calculated relationship - identity or similarity """ relationship = self.adv_opts_dlg.model.relationship.value text = f"Calculated using {relationship}" self.ui.status_lbl.setText(text)