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