import io
import os
import sys
from functools import lru_cache
from past.utils import old_div
from typing import List
from typing import Optional
from typing import Tuple
import numpy as np
from rdkit import Chem
from rdkit.Chem.rdchem import Mol
from schrodinger import structure
from schrodinger.infra import canvas2d
from schrodinger.livedesign import substructure
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.Qt.QtGui import QColor
from schrodinger.rdkit.alignment import generate_min_height_coords
from schrodinger.structutils import analyze
from schrodinger.ui import sketcher
from schrodinger.ui.qt import icons
from schrodinger.ui.qt import structure2d
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.standard.colors import LightModeColors
# Flags for including/removing an entry from the workspace
WS_REMOVE, WS_INCLUDE, WS_INCLUDE_ONLY = list(range(3))
# Custom Roles
NUM_INCLUDED_ENTRIES_ROLE = Qt.UserRole + 657390
_MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
# A dictionary of {icon path: QImage}. We can't create the QImage now since it
# requires QApplication instance, which may not yet exist. Instead, QImages are
# instantiated in AbstractDelegateWithEditIcon.__init__.
_EDIT_ICONS = {}
# Data role for sending text information to PictureDelegate
PICTURE_TEXT_ROLE = Qt.UserRole + 9000
[docs]class HashableChmMol:
"""
A `ChmMol` wrapper that extracts data from the `ChmMol` object and uses it
to present a useful hash value. This is necessary to ensure that a `ChmMol`
instance is not accidentally matched with a totally different `ChmMol`
because they happen to share the same memory location (at different times).
All hashable data is extracted at the moment this object is initialized, so
this class is only meant as a transient wrapper for internal use within
`ChmMolDelegate`.
"""
[docs] def __init__(self, chmmol):
"""
Parse and store significant data from a `ChmMol` object.
:param chmmol: a `ChmMol` instance
:type chmmol: canvas2d.ChmMol
:ivar _coords: the Cartesian coordinates of the stored `ChmMol`
instance, saved at the moment this object was initialized
:vartype _coords: tuple(tuple(float))
:ivar _atoms: the atomic symbols of the stored `ChmMol` instance, saved
at the moment this object was initialized
:vartype _atoms: tuple(str)
:ivar _smarts: the SMARTS string of the stored `ChmMol` instance, saved
at the moment this object was initialized
:vartype _smarts: str
"""
self._chmmol = chmmol
atoms = [chmmol.getAtom(idx) for idx in range(chmmol.getAtomCount())]
self._coords = tuple((a.getX(), a.getY(), a.getZ()) for a in atoms)
self._atoms = tuple(a.getSymbol() for a in atoms)
self._smarts = chmmol.getSMARTS()
@property
def chmmol(self):
"""
:return: the wrapped `ChmMol` instance
:rtype: canvas2d.ChmMol
"""
return self._chmmol
def __hash__(self):
return hash((self.chmmol, self._coords, self._atoms, self._smarts))
def __eq__(self, other):
if not isinstance(other, HashableChmMol):
return False
return hash(self) == hash(other)
[docs]class CenteredIconDelegate(QtWidgets.QStyledItemDelegate):
"""
Displays a center-aligned icon based on a `QPixmap` received from
`Qt.DecorationRole`. Does nothing if `Qt.DecorationRole` returns `None`.
"""
[docs] def paint(self, painter, option, index):
"""
Paint the icon supplied by `QDecorationRole` into each cell.
:param painter: The painter being used to render the delegate
:type painter: QPainter
:param option: The style options to use when painting
:type option: QStyleOptionViewItem
:param index: The index being represented
:type index: QtCore.QModelIndex
"""
pixmap = index.data(Qt.DecorationRole)
icon = QtGui.QIcon(pixmap)
if icon is not None:
align = Qt.AlignCenter
icon.paint(painter, option.rect, align)
[docs]class AbstractDelegateWithEditIcon(QtWidgets.QStyledItemDelegate):
"""
A base class for delegates that paint an edit icon.
:cvar EDIT_ICON: The file name for the icon to paint on editable cells.
This file must be in the same directory as this module.
:vartype EDIT_ICON: str
"""
EDIT_ICON = icons.PENCIL_ICON
[docs] def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.EDIT_ICON not in _EDIT_ICONS:
_EDIT_ICONS[self.EDIT_ICON] = QtGui.QImage(self.EDIT_ICON)
self._edit_icon = _EDIT_ICONS[self.EDIT_ICON]
def _drawEditIcon(self, painter, option, index):
"""
For use in the paint() method of delegates that allow editing of values.
Draws `EDIT_ICON` in the right-side of the cell if that cell has
ItemIsEnabled and ItemIsEditable flags set.
"""
flags = index.flags()
if not flags & Qt.ItemIsEnabled or not flags & Qt.ItemIsEditable:
return
render_size = QtCore.QSize(12, 12)
icon_rect = QtWidgets.QStyle.alignedRect(
Qt.LeftToRight, Qt.AlignRight | Qt.AlignVCenter, render_size,
option.rect)
# Add a buffer on the right:
icon_rect.translate(-4, 0)
painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform)
painter.drawImage(icon_rect, self._edit_icon)
[docs]class LineEditDelegate(AbstractDelegateWithEditIcon):
"""
Delegate recommended for use with all Schrodinger table model. It's just
like the standard Qt delegate, except that if a table cell has the
ItemIsEditable flag set, it will draw a "pencil" icon on the right side
of the cell.
QLineEdit widget will be shown for cells that return str values for data,
and QSpinBox widget will be shown for cells that return int or float values.
To limit allowed min/max values, use SpinBoxDelegate.
"""
# TODO consider using QLineEdit widget even for numeric values, or
# renaming this class.
[docs] def paint(self, painter, option, index):
# See Qt documentation for method documentation
super(LineEditDelegate, self).paint(painter, option, index)
view = self.parent()
# Don't draw the edit icon when editor is up
if (isinstance(view, QtWidgets.QTableView) and
view.state() == view.EditingState):
return
self._drawEditIcon(painter, option, index)
[docs]class MouseTrackingDelegateMixin:
"""
A mixin for a `QtWidgets.QStyledItemDelegate` that tracks which cell the
mouse is in.
"""
[docs] def __init__(self, view):
"""
:param view: The view that this delegate will be added to. Note that
mouse tracking will be enabled in the view
:type view: QtWidgets.QTableView
"""
super().__init__(view)
self._mouse_rc = None # row and column value, or None
view.setMouseTracking(True)
view.viewport().installEventFilter(self)
@property
def mouse_data(self):
"""
Returns the data for the cell that the mouse is in.
:return: the data for the cell that the mouse is on or None
:rtype: object
"""
if self._mouse_rc is None:
return None
view = self.parent()
model = view.model()
index = model.index(*self._mouse_rc)
return index.data()
[docs] def createEditor(self, parent, option, index):
"""
This delegate does not have a separate editor widget, so we return None.
All arguments are ignored, but are present for Qt compatibility.
"""
return None
[docs] def eventFilter(self, viewport, event):
"""
The delegate does not receive Leave events or MouseMove events that
don't occur over an index (e.g. events that occur over a header or over
blank space in the view), so we have to act as an event filter for the
view's viewport to observe these events.
:param viewport: The view's viewport. Not used, but present for
compatibility with Qt's event filtering.
:type viewport: QtWidgets.QWidget
:param event: The event to filter
:type event: QtCore.QEvent
:return: True if the event was handled and does not need to be passed to
the viewport. False otherwise. We want all events to be passed to
the viewport, so we always return False.
:rtype: bool
"""
if event.type() not in (event.MouseMove, event.Leave):
# This filter is only concerned with specific cursor-related events
return False
model = self.parent().model()
if event.type() == event.Leave:
if self._mouse_rc is not None:
row, col = self._mouse_rc
self._mouse_rc = None
self._redrawRc(model, row, col)
elif event.type() == event.MouseMove:
view = self.parent()
pos = event.pos()
index = view.indexAt(pos)
prev_rc = self._mouse_rc
self._mouse_rc = self._getRc(index)
if self._mouse_rc == (-1, -1):
self._mouse_rc = None
if self._mouse_rc != prev_rc:
self._redrawIndex(model, index)
return False
def _redrawIndex(self, model, index):
"""
Alert the view that the specified index needs to be redrawn.
We use the model's dataChanged signal to accomplish this.
:param model: The data model.
:type model: QtCore.QAbstractTableModel
:param index: The index to redraw.
:type index: QtCore.QModelIndex
"""
model.dataChanged.emit(index, index)
def _getRc(self, index):
"""
Get the row and column of the specified index.
:param index: The table index to get (row, column) of
:type index: QtCore.QModelIndex or NoneType
:return: If the index is not None, return (row, column).
Otherwise, return None.
:rtype: tuple or NoneType
"""
if index is not None:
return (index.row(), index.column())
def _redrawRc(self, model, row, col):
"""
Alert the view that the cell at the specified row and column needs to be
redrawn.
We use the model's dataChanged signal to accomplish this.
:param model: The data model.
:type model: QtCore.QAbstractTableModel
:param row: The row of the index to redraw
:type row: int
:param col: The column of the index to redraw
:type col: int
"""
index = model.index(row, col)
self._redrawIndex(model, index)
[docs]class SpinBoxDelegate(LineEditDelegate):
"""
A delegate that provides a `QtWidgets.QSpinBox` as an editor. The minimum and
maximum allowed values for the spin box are set using data retrieved
via the SPINBOX_LIMITS_ROLE.
Editing is initiated when the cell is clicked. Note that the mouse down and
up must both occur in the same cell; otherwise, editing will not be started.
This allows the user to highlight multiple cells by clicking and dragging.
NOTE: Use STableView in order to enter the edit mode after a click.
"""
SPINBOX_LIMITS_ROLE = Qt.UserRole + 101
# Tell STableView to enter the edit mode when the user clicks in the cell:
MOUSE_CLICK_STARTS_EDIT = True
[docs] def createEditor(self, parent, option, index):
# See Qt documentation for method documentation
spin = QtWidgets.QSpinBox(parent)
spin_min, spin_max = index.data(self.SPINBOX_LIMITS_ROLE)
spin.setRange(spin_min, spin_max)
return spin
[docs] def setEditorData(self, editor, index):
# See Qt documentation for method documentation
data = index.data(Qt.EditRole)
editor.setValue(data)
[docs] def setModelData(self, editor, model, index):
# See Qt documentation for method documentation
data = editor.value()
model.setData(index, data)
[docs] def updateEditorGeometry(self, editor, option, index):
# See Qt documentation for method documentation
editor.setGeometry(option.rect)
class _TableComboBox(QtWidgets.QComboBox):
"""
A ComboBox with some modifications:
1. It is never actually drawn - ComboBoxDelegate.paint() handles drawing
the contents of the cells.
2. Releasing the mouse when the menu is up does not automatically close it
if the release is within "drag timeout" of the initial press. This allows
the user to click the cell to bring up the menu, move the mouse, and
click on the new item to select it.
3. Emits popUpClosed when the menu is closed.
"""
popUpClosed = QtCore.pyqtSignal()
def __init__(self, parent=None):
"""
:param parent: The parent widget.
:type parent: `QWidget`
"""
super(_TableComboBox, self).__init__(parent)
self.ignore_next_hide_popup = True
drag_time = QtWidgets.QApplication.instance().startDragTime()
QtCore.QTimer.singleShot(drag_time, self._dragTimedOut)
def _dragTimedOut(self):
self.ignore_next_hide_popup = False
def paintEvent(self, event):
# Do not actually draw a combo box - use it only for the menu.
# The contents of cells will be rendered by ComboBoxDelegate.paint().
return
def hidePopup(self):
# See Qt documentation for method documentation
if self.ignore_next_hide_popup:
self.ignore_next_hide_popup = False
return
super(_TableComboBox, self).hidePopup()
QtCore.QTimer.singleShot(0, self.popUpClosed.emit)
[docs]class ComboBoxDelegate(AbstractDelegateWithEditIcon):
"""
A delegate that provides a `_TableComboBox` as an editor. The combo box is
populated using a list or OrderedDict retrieved via the COMBOBOX_ROLE.
NOTE: Use STableView in order to open the combo menu with a single click.
"""
COMBOBOX_ROLE = Qt.UserRole + 102
# Tell STableView to enter the edit mode when the user presses the mouse in
# the cell. This properly handles both a click (press & release) and a drag.
MOUSE_PRESS_STARTS_EDIT = True
EDIT_ICON = icons.COMBOBOX_ICON
[docs] def paint(self, painter, option, index):
# See Qt documentation for method documentation
# Draw the label (selected item text):
super(ComboBoxDelegate, self).paint(painter, option, index)
# Draw the down-pointing triangle:
self._drawEditIcon(painter, option, index)
[docs] def createEditor(self, parent, option, index):
# See Qt documentation for method documentation
combo = _TableComboBox(parent)
combo.popUpClosed.connect(lambda: self._popUpClosed(combo))
items = index.data(self.COMBOBOX_ROLE)
if isinstance(items, list):
# Use each value for both representation and data
for value in items:
combo.addItem(value, value)
else:
# Assume a dictionary
for key, val in items.items():
combo.addItem(key, val)
# Show the pop up only after the current item is selected based on the
# contents of the model:
QtCore.QTimer.singleShot(0, combo.showPopup)
return combo
[docs] def setEditorData(self, editor, index):
# See Qt documentation for method documentation
data = index.data(Qt.EditRole)
for combo_index in range(editor.count()):
if data == editor.itemData(combo_index):
editor.setCurrentIndex(combo_index)
return
raise ValueError("Data not in combo box: %s" % data)
[docs] def setModelData(self, editor, model, index):
# See Qt documentation for method documentation
combo_index = editor.currentIndex()
data = editor.itemData(combo_index)
model.setData(index, data)
def _popUpClosed(self, editor):
"""
Respond to the combo box pop up closing by closing the editor.
:param editor: The combo box that was closed.
:type editor: `_TableComboBox`
"""
self.commitData.emit(editor)
self.closeEditor.emit(editor, self.NoHint)
[docs]class MatplotlibDelegate(QtWidgets.QStyledItemDelegate):
"""
A delegate that displays a matplotlib plot for all data of type
PLOT_DATA_TYPE. Data of other types will be displayed normally (i.e. as a
string). A small plot (generated in `_genCellPlot`) will be shown inside of
the cell and a larger plot (generated in `_genToolTipPlot`) will be shown
in a tool-tip-style pop up if the user hovers over a cell. Data for the
small plot is retrieved using Qt.UserRole. Data for the tool tip plot is
retrieved using Qt.ToolTipRole.
This class should not be instantiated directly and must be subclassed.
Subclasses must redefine `_genCellPlot` and may also redefine
`_genToolTipPlot` and the class variables.
:cvar TOOLTIP_PLOT: Whether the matplotlib tool tip pop up should be used.
If this is False, all of the other TOOLTIP_PLOT_* constants will be ignored,
as will `_genToolTipPlot`.
:vartype TOOLTIP_PLOT: bool
:cvar TOOLTIP_PLOT_WIDTH: The width of the plots shown in the tool tip
popup
:vartype TOOLTIP_PLOT_WIDTH: int
:cvar TOOLTIP_PLOT_HEIGHT: The height of the plots shown in the tool tip
popup
:vartype TOOLTIP_PLOT_HEIGHT: int
:cvar TOOLTIP_FRAME_WIDTH: The thickness of the border around the tool tip
plot popup
:vartype TOOLTIP_FRAME_WIDTH: int
:cvar PLOT_DATA_TYPE: Data of this type (or tuple of types) will result in a
matplotlib plot. Other data types will result in standard table behavior.
:vartype PLOT_DATA_TYPE: type or tuple
"""
TOOLTIP_PLOT = True
TOOLTIP_PLOT_WIDTH = 640
TOOLTIP_PLOT_HEIGHT = 480
TOOLTIP_FRAME_WIDTH = 1
PLOT_DATA_TYPE = (list, tuple)
[docs] def __init__(self, parent=None):
super(MatplotlibDelegate, self).__init__(parent)
self._popup = _ToolTipPlotHolder(parent, self.TOOLTIP_PLOT_WIDTH,
self.TOOLTIP_PLOT_HEIGHT,
self.TOOLTIP_FRAME_WIDTH)
self._popup_index = None
[docs] def createEditor(self, parent, option, index):
"""
This delegate does not have a separate editor widget, so we return None.
All arguments are ignored, but are present for Qt compatibility.
"""
return None
[docs] def paint(self, painter, option, index):
"""
Paint the cell contents
:param painter: The painter being used to render the delegate
:type painter: QPainter
:param option: The style options to use when painting
:type option: QStyleOptionViewItem
:param index: The index being represented
:type index: `PyQt5.QtCore.QModelIndex`
"""
data = index.data()
if isinstance(data, self.PLOT_DATA_TYPE):
self._paintBlank(painter, option)
self._paintPlot(painter, option, data)
else:
super(MatplotlibDelegate, self).paint(painter, option, index)
def _paintBlank(self, painter, option):
"""
Paint a blank cell so that the selection background and the current
selected cell outline (i.e. dotted line) get painted.
:param painter: The painter to use for painting
:type painter: `PyQt5.QtGui.QPainter`
:param option: The options to use for painting
:type option: `PyQt5.QtWidgets.QStyleOptionViewItem`
"""
blank_index = QtCore.QModelIndex()
super(MatplotlibDelegate, self).paint(painter, option, blank_index)
def _paintPlot(self, painter, option, data):
"""
Paint the matplotlib plot into a cell
:param painter: The painter to use for painting
:type painter: `PyQt5.QtGui.QPainter`
:param option: The options to use for painting
:type option: `PyQt5.QtWidgets.QStyleOptionViewItem`
:param data: The data to plot
:type data: PLOT_DATA_TYPE
"""
canvas = self._genCellPlot(data)
# resizing the canvas won't work unless the canvas is shown, so we
# resize the figure instead
size = option.rect.size()
self._setFigureSize(canvas, size)
pixmap = QtGui.QPixmap(size)
pixmap.fill(QtGui.QColor(0, 0, 0, 0))
# Save figure in buffer, then read it in pixmap
buff = io.BytesIO()
canvas.print_figure(buff)
pixmap.loadFromData(buff.getbuffer())
painter.drawPixmap(option.rect, pixmap)
def _setFigureSize(self, canvas, size):
"""
Set the figure size to fill the rect
:param canvas: the canvas
:type canvas: `schrodinger.mpl_backend_agg.FigureCanvasQTAgg`
:param size: the required size of the figure
:type size: QtCore.QSize
"""
figure = canvas.figure
width, height = size.width(), size.height()
dpi = figure.dpi
figure.set_size_inches(width / dpi, height / dpi, True)
def _genCellPlot(self, data):
"""
Generate the matplotlib plot to paint inside of the cell. This function
must be defined in subclasses.
:param data: The data to plot
:type data: PLOT_DATA_TYPE
:return: The canvas to paint
:rtype: `schrodinger.mpl_backend_agg.FigureCanvasQTAgg`
"""
raise NotImplementedError
def _genToolTipPlot(self, data):
"""
Generate the matplotlib plot to paint inside of the tool tip pop up. If
this function is not defined in a subclass, `genCellPlot` will be used.
:param data: The data to plot
:type data: PLOT_DATA_TYPE
:return: The canvas to paint
:rtype: `schrodinger.mpl_backend_agg.FigureCanvasQTAgg`
"""
return self._genCellPlot(data)
[docs] def helpEvent(self, event, view, option, index):
"""
Handle help events. If the index has data of type self.PLOT_DATA_TYPE
for Qt.ToolTipRole, a pop up plot will be generated. All other events
will be handled as usual. Before generating a pop up, make sure that a
pop up for the given index isn't already displayed. If it is, don't do
anything.
:param event: The event to be handled
:type event: `QtGui.QHelpEvent`
:param view: The view that this delegate is used in
:type view: `QtWidgets.QTableView`
:param option: Ignored, but present for Qt compatibility.
:param index: The index of the cell where the event occurred
:type index: `QtCore.QModelIndex`
:return: True if the event was handled. False otherwise.
:rtype: bool
"""
data = index.data(Qt.ToolTipRole)
if self.TOOLTIP_PLOT and isinstance(data, self.PLOT_DATA_TYPE):
if (not self._popup.isVisible() or
(index.row(), index.column()) != self._popup_index):
self._popup_index = (index.row(), index.column())
# If do self._popup_index = index here, then occasionally
# self._popup_index will be modified before the next time this
# function is called. Presumably, something is happening C++
# that modifies the index in place after this function returns.
# We get around this by storing (row, column) instead of a
# reference to the passed QModelIndex object.
canvas = self._genToolTipPlot(data)
pos = event.globalPos()
cell_rect = view.visualRect(index)
top_left = cell_rect.topLeft()
top_left = view.mapToGlobal(top_left)
cell_rect.moveTopLeft(top_left)
self._popup.showPlot(canvas, pos, cell_rect)
return True
return super(MatplotlibDelegate,
self).helpEvent(event, view, option, index)
class _ToolTipPlotHolder(QtWidgets.QFrame):
"""
A widget to hold the matplotlib tool tip plot generated in
`MatplotlibDelegate`. The implementation of this class is based loosely on
Qt's QTipLabel implementation.
"""
def __init__(self, parent, plot_width, plot_height, frame_width=1):
"""
Create a plot holder, but do not display it
:param parent: The Qt parent widget
:type parent: `QtWidgets.QWidget`
:param plot_width: The width to display the plot, in pixels
:type plot_width: int
:param plot_height: The height to display the plot, in pixels
:type plot_height: int
:param frame_width: The width of the frame around the plot, in pixels
:type frame_width: int
"""
super(_ToolTipPlotHolder, self).__init__(parent, Qt.ToolTip)
self._plot_width = plot_width
self._plot_height = plot_height
self._frame_width = frame_width
self._widget_width = plot_width + 2 * frame_width
self._widget_height = plot_height + 2 * frame_width
self.setFocusPolicy(Qt.NoFocus)
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.setLineWidth(self._frame_width)
self.setFrameShape(self.Box)
self.setFrameShadow(self.Plain)
self._canvas = None
self._rect = None
self._hide_timer = QtCore.QTimer(self)
self._hide_timer.setInterval(300)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self.hide)
self.setGeometry(0, 0, self._widget_width, self._widget_height)
def _clearPlot(self):
"""
If there is a plot loaded into this widget, delete it
"""
if self._canvas is not None:
self._canvas.setParent(None)
self._canvas = None
def eventFilter(self, qapp, event):
"""
Check all QApplication events to figure out when to hide the pop up
:param qapp: The QApplication instance being filtered. Ignored, but
present for Qt compatibility.
:type qapp: `QtWidgets.QApplication`
:param event: The event to check
:type event: `QtCore.QEvent`
:return: Whether the event have been handled and should no longer be
propagated. Since we don't actually want to filter out any events,
False is always returned.
:rtype: bool
"""
if event.type() == event.MouseMove:
if not self._rect.contains(event.globalPos()):
self.delayedHide()
elif event.type() in (event.WindowActivate, event.WindowDeactivate,
event.MouseButtonPress, event.MouseButtonRelease,
event.MouseButtonDblClick, event.FocusIn,
event.FocusOut, event.Wheel):
self.hide()
return False
def showPlot(self, canvas, pos, rect):
"""
Show the given plot in a pop up at the specified point
:param canvas: The plot to display
:type canvas: `schrodinger.mpl_backend_agg.FigureCanvasQTAgg`
:param pos: The current mouse cursor location. Must use global
coordinates.
:type pos: `QtCore.QPoint`
:param rect: The rectangle containing the table cell that the pop up is
being displayed for. The pop up will be hidden when the mouse cursor
leaves this rectangle. Must use global coordinates.
:type rect: `QtCore.QRect`
"""
QtWidgets.QToolTip.hideText() # Clear any existing tool tip
self._hide_timer.stop()
self._clearPlot()
canvas.setParent(self)
canvas.show()
self._canvas = canvas
self._rect = rect
canvas.setGeometry(self._frame_width, self._frame_width,
self._plot_width, self._plot_height)
plot_pos = self._calcPlotLocation(pos)
self.move(plot_pos)
self.show()
qapp = QtWidgets.QApplication.instance()
qapp.installEventFilter(self)
def delayedHide(self):
"""
Hide the pop up after a short delay. This mimics the standard Qt tool
tip delay.
"""
if not self._hide_timer.isActive():
self._hide_timer.start()
def hideEvent(self, event):
"""
Clean up when hiding the pop up.
:param event: The event that triggered the hide
:type event: `QtCore.QEvent`
"""
self._hide_timer.stop()
self._clearPlot()
qapp = QtWidgets.QApplication.instance()
qapp.removeEventFilter(self)
self._rect = None
super(_ToolTipPlotHolder, self).hideEvent(event)
def _calcPlotLocation(self, pos):
"""
Determine where to place the tool tip plot. Make sure that it is fully
visible and that it's located on a single monitor.
:param pos: The mouse location
:type pos: `QtCore.QPoint`
:return: The location for the upper-left corner of the tool tip plot
:rtype: `QtCore.QPoint`
:note: This code is based on Qt's tool tip placement
(src/gui/kernel/qtooltip.cpp QTipLabel::placeTip), and the constants
used below are taken from that code. Unlike placeTip, this function
does not take the OS X dock into account.
"""
desktop = QtWidgets.QApplication.desktop()
tip_screen = desktop.screenNumber(pos)
screen_geom = desktop.screenGeometry(tip_screen)
if os.name == "nt":
offset = QtCore.QPoint(2, 21)
else:
offset = QtCore.QPoint(2, 16)
new_pos = pos + offset
screen_left = screen_geom.x()
screen_top = screen_geom.y()
# QRect.right() and QRect.bottom() are off by one for
# historical reasons, so we calculate the real values here
screen_right = screen_geom.x() + screen_geom.width()
screen_bottom = screen_geom.y() + screen_geom.height()
if new_pos.x() + self._widget_width > screen_right:
new_x = new_pos.x() - 4 - self._widget_width
new_pos.setX(new_x)
if new_pos.x() < screen_left:
new_pos.setX(screen_left)
if new_pos.y() + self._widget_height > screen_bottom:
new_y = new_pos.y() - 24 - self._widget_height
new_pos.setY(new_y)
if new_pos.y() < screen_top:
new_pos.setY(screen_top)
return new_pos
[docs]class ModelIndexFilter(QtCore.QModelIndex):
"""
An index that can override the data provided by a model.
:note: Qt's QModelIndex functions are all inlined. As a result, any
functions implemented in a QModelIndex subclass are bypassed when called
from C++. Because of this, we have to implement this class using a real
index to a dummy model (`_DummyModel`) rather than using a dummy index with
its own data() method.
"""
def __new__(cls, data, index):
"""
:param data: The data to provide, as a dictionary of {role: data}
:type data: dict
:param index: An index to query for data roles not present in `data`
:type index: `QtCore.QModelIndex`
"""
model = _DummyModel(data, index)
index = model.index(0)
index._model = model
return index
class _DummyModel(QtCore.QAbstractListModel):
"""
A dummy model used in `ModelIndexFilter`
"""
def __init__(self, data, index):
"""
See documentation for `ModelIndexFilter.__new__` above.
"""
super(_DummyModel, self).__init__()
self._data = data
self._index = index
def rowCount(self, parent=None):
return 1
def data(self, index, role=Qt.DisplayRole):
"""
If we have data for the requested role in `self._data`, return that.
Otherwise, return data from `self._index`.
See Qt documentation for an explanation of arguments and return value.
"""
if role in self._data:
return self._data[role]
else:
return self._index.data(role)
[docs]class DefaultMessageDelegate(QtWidgets.QStyledItemDelegate):
"""
A delegate that displays a default "Double-click to edit" message when there
is no data to display. This way, the model and the editor don't have to
treat no-data as a special case. Note that the model background color will
still be used.
"""
FONT = QtGui.QFont()
FONT.setItalic(True)
FOREGROUND = QtGui.QBrush(Qt.lightGray)
[docs] def __init__(self, parent=None, message="Double-click to edit..."):
super(DefaultMessageDelegate, self).__init__(parent=parent)
self.data = {
Qt.DisplayRole: message,
Qt.FontRole: self.FONT,
Qt.ForegroundRole: self.FOREGROUND
}
[docs] def paint(self, painter, option, index):
"""
If the DisplayRole data for index is empty, paint the default message
instead.
See Qt documentation for an explanation of arguments.
"""
display_data = index.data()
if not display_data:
index = ModelIndexFilter(self.data, index)
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
[docs]class AbstractCustomDelegate(QtWidgets.QStyledItemDelegate):
[docs] def paintItemBackground(self, painter, option, index):
"""
Paints the item's background.
:param painter: The painter to use
:type painter: `QtGui.QPainter`
:param option: The options for the cell
:type option: `QtWidgets.QStyleOptionViewItem`
:param index: The index to paint
:type index: `QtCore.QModelIndex`
:return: Instance of `QtWidgets.QStyleOptionViewItem` initialized using
original option and an instance of `QStyle`
:rtype: tuple of `QtWidgets.QStyleOptionViewItem`, `QStyle`
"""
opt = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
widget = opt.widget
if widget is not None:
style = widget.style()
else:
style = QtWidgets.QApplication.style()
style.drawPrimitive(style.PE_PanelItemViewItem, opt, painter, widget)
return opt, style
[docs]class WorkspaceInclusionDelegate(AbstractCustomDelegate):
"""
A delegate for representing workspace inclusion as it is shown in the
project table. This delegate allows an entry to be set as the only
workspace entry by clicking, or allows workspace inclusion to be toggled by
holding down Ctrl.
NOTE: If using schrodinger.ui.qt.delegates.WorkspaceInclusionDelegate, this
delegate expects the model to provide the number of included entries for
NUM_INCLUDED_ENTRIES_ROLE. If this data is not provided, a black inclusion
icon will be used for any number of inclusions, including 1.
"""
[docs] def __init__(self, parent):
super(WorkspaceInclusionDelegate, self).__init__(parent)
self._black_icon = QtGui.QIcon()
self._black_icon.addFile(":/icons/include_checked_maestrox.png",
state=QtGui.QIcon.On)
self._black_icon.addFile(":/icons/include_unchecked_maestrox.png",
state=QtGui.QIcon.Off)
self._red_icon = QtGui.QIcon()
self._red_icon.addFile(":/icons/include_red_checked_maestrox.png",
state=QtGui.QIcon.On)
self._red_icon.addFile(":/icons/include_unchecked_maestrox.png",
state=QtGui.QIcon.Off)
[docs] def createEditor(self, parent, option, index):
"""
This delegate does not have a separate editor widget, so we return None.
All arguments are ignored, but are present for Qt compatibility.
"""
return None
[docs] def paint(self, painter, option, index):
"""
Paint the appropriate icon
:param painter: The painter being used to render the delegate
:type painter: QPainter
:param option: The style options to use when painting
:type option: QStyleOptionViewItem
:param index: The index being represented
:type index: `PyQt5.QtCore.QModelIndex`
"""
self.paintItemBackground(painter, option, index)
if index.flags() & Qt.ItemIsEnabled:
mode = QtGui.QIcon.Active
else:
mode = QtGui.QIcon.Disabled
enabled = index.data()
state = QtGui.QIcon.On if enabled else QtGui.QIcon.Off
num_included = index.data(NUM_INCLUDED_ENTRIES_ROLE)
if num_included is None or num_included > 1:
icon = self._black_icon
else:
icon = self._red_icon
align = Qt.AlignCenter
icon.paint(painter, option.rect, align, mode, state)
[docs] def editorEvent(self, event, model, option, index):
"""
Handle mouse clicks and key presses
:param event: The event that occurred
:type event: `PyQt5.QtCore.QEvent`
:param model: The data model
:type model: QAbstractTableModel
:param option: Ignored, but present for Qt compatibility
:param index: The index being edited
:type index: `PyQt5.QtCore.QModelIndex`
:return: True if the event was handled. False otherwise.
:rtype: bool
"""
# We could limit the mouse click by making sure that it's inside the
# check box, but the project table accepts clicks anywhere in the cell,
# so we don't bother to limit things that way.
mouse_click = (event.type() == event.MouseButtonPress and
event.button() == Qt.LeftButton)
key_press = (event.type() == event.KeyPress and
event.key() in (Qt.Key_Space, Qt.Key_Select))
ctrl = event.modifiers() & Qt.ControlModifier
if mouse_click or key_press:
if not ctrl:
enabled = WS_INCLUDE_ONLY
elif index.data():
enabled = WS_REMOVE
else:
enabled = WS_INCLUDE
model.setData(index, enabled)
return True
else:
return False
[docs]class PictureDelegate(AbstractCustomDelegate):
"""
An abstract delegate class that can render images along with text.
This delegate queries the table's `Qt.DisplayRole` for data that informs
how it should render an image. This data must be an instance of
`DISPLAY_DATA_CLASS`. If not, this delegate will revert to default delegate
behavior.
This delegate also queries the custom `PICTURE_TEXT_ROLE`
role for text to display along with the generated image. The text color can
be set if the table returns the appropriate `QtGui.QBrush` when
`Qt.ForegroundRole` is queried.
Images should be cached for faster rendering after the first render. They
are stored in a cache that can be cleared via the `clearImageCache` method
when the model's data changes, if necessary.
Subclasses should define the following:
-DISPLAY_DATA_CLASS: class variable that indicates the expected type of
the display data to be received from the table.
-_getPicture(): method that actually generates the picture to be
rendered. Should only be accessed via the `getPicture()` method
:ivar padding_factor: The portion of a cell's width and height to be used as
padding on each side of the cell when drawing the image. Default value
0.04.
"""
TEXT_MARGIN = 2
DISPLAY_DATA_CLASS = None
[docs] def __init__(self, parent=None, padding_factor=0.04, alignment=None):
"""
Initialize a new delegate instance, optionally specifying the size of
the padding in the cell around the image.
:param padding_factor: The portion of the cell's width and height to be
used as padding on each side of the cell when drawing the image.
:type padding_factor: `float`
:param alignment: The alignment of the text, if any text is provided by
the cell data method. Vertical alignment specifies whether the text
is displayed above or below the structure image.
:type alignment: `QtCore.QAlignment`
"""
super(PictureDelegate, self).__init__(parent)
# Using lru_cache() prevents _getCachedPicture() from being called if
# its return value for the provided arguments is already stored in the
# cache. It also adds the following functions to this method:
# cache_info() and cache_clear(). Note that we avoid using lru_cache()
# as a decorator, as that will store the cache data on the class.
# Instead, by applying it during __init__(), the cache data is stored on
# the instance.
self._getCachedPicture = lru_cache(maxsize=1024)(self._getCachedPicture)
self.setPaddingFactor(padding_factor)
if alignment is None:
alignment = Qt.AlignLeft | Qt.AlignTop
self.setAlignment(alignment)
self.setTextElide(Qt.ElideRight)
self.adaptor = canvas2d.ChmMmctAdaptor()
model2d = canvas2d.ChmRender2DModel()
self.renderer = canvas2d.Chm2DRenderer(model2d)
[docs] def setAlignment(self, alignment):
"""
Set the text alignment for the cell. The vertical component of the
alignment indicates whether the text will be displayed above or below
the structure image.
The vertical component of the alignment must either be `Qt.AlignTop`
or `Qt.AlignBottom`; if it is neither of these, the vertical alignment
will default to `Qt.AlignTop`.
If this delegate is not provided with both a structure and text to
display, this alignment setting is ignored.
:param alignment: Text alignment in table cell.
:type alignment: `QtCore.Alignment`
:raise ValueError: if the vertical component of the alignment is
specified to be Qt.AlignVCenter
"""
if alignment & Qt.AlignVCenter:
raise ValueError('Vertical alignment must either be Qt.AlignTop or'
' Qt.AlignBottom, not Qt.AlignVCenter.')
# If no vertical alignment is specified, default to Qt.AlignTop
if not alignment & Qt.AlignTop and not alignment & Qt.AlignBottom:
alignment |= Qt.AlignTop
self._alignment = alignment
[docs] def alignment(self):
return self._alignment
[docs] def setTextElide(self, elide):
"""
Set the text elide status of the text displayed by the delegate. The
elide mode describes where the ellipsis should appear when displaying
text that doesn't fit into the available space.
:param elide: text elide mode
:type elide: `Qt.TextElideMode`
"""
self._elide = elide
[docs] def textElide(self):
return self._elide
[docs] def setPaddingFactor(self, padding_factor):
"""
Set the relative size of the cell width and height used as padding for
the image.
If the table view has already been painted, `view.update` should be
called after `setPaddingFactor` to make sure that the visible cells are
properly updated.
:raise TypeError: when the supplied `padding_factor` is not a float
:raise ValueError: when the supplied `padding_factor` is less than 0.0
or greater than 0.5.
:param padding_factor: The portion of the cell's width and height to be
used as padding on each side of the cell when drawing the image.
:type padding_factor: `float`
"""
error_msg = 'Image padding factor must be a float between 0 and 0.5.'
if not isinstance(padding_factor, float) and padding_factor != 0:
raise TypeError(error_msg)
if padding_factor < 0.0 or padding_factor > 0.5:
raise ValueError(error_msg)
self.padding_factor = padding_factor
[docs] def paddingFactor(self):
return self.padding_factor
[docs] def paint(self, painter, option, index):
"""
Arrange and paint image, and optionally text, into the table cell.
See `QtWidgets.QAbstractItemDelegate.paint` for argument documentation.
"""
# Parse data from the table model
display_data = index.data(Qt.DisplayRole)
if not isinstance(display_data, self.DISPLAY_DATA_CLASS):
# Unexpected input; revert to default delegate behavior
super(PictureDelegate, self).paint(painter, option, index)
return
# Paint background (so that if this cell is selected it is highlighted)
self.paintItemBackground(painter, option, index)
# Obtain the rectangle in which the painting will take place
rect = self.getContentRect(option.rect)
# Draw text into cell, if any has been provided
text_rect = self.paintText(painter, option, index, rect)
# Prepare sub-rectangle to paint image into; depending on vertical text
# alignment, place image above or below text
image_rect = QtCore.QRect(rect)
if text_rect:
if self._alignment & Qt.AlignTop:
image_rect.setTop(text_rect.bottom() + self.TEXT_MARGIN)
elif self._alignment & Qt.AlignBottom:
image_rect.setBottom(text_rect.top() - self.TEXT_MARGIN)
# Paint image into table cell
self.paintPicture(display_data, painter, image_rect)
[docs] def paintText(self, painter, option, index, rect):
"""
Paint supplied text into a subsection of the supplied rectangle.
:param painter: The painter that is drawing in the table
:type painter: `QtGui.QPainter`
:param option: Class containing parameters used to draw the cell
:type option: `QtGui.QStyleOptionViewItem`
:param index: Index for the cell to be painted
:type index: `QtCore.QModelIndex`
:param rect: The rectangle in which the text should be painted
:type rect: `QtCore.QRect`
:return: If text is drawn, a rectangle contained entirely within the
larger painting area that contains the painted text, or `None` if
no text was provided.
:rtype: `QtCore.QRect` or `None`
"""
text = index.data(role=PICTURE_TEXT_ROLE)
if not text:
return
# Make a copy of the option object (so that the original is not changed
# when it is used elsewhere in the delegate logic)
option = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(option, index)
# Apply formatting to painter before painting text
painter.setFont(option.font)
palette = option.palette
if option.state & QtWidgets.QStyle.State_Selected:
painter.setPen(
palette.color(palette.Normal, palette.HighlightedText))
else:
painter.setPen(palette.color(palette.Normal, palette.Text))
brush = index.data(role=Qt.ForegroundRole)
if brush:
painter.setBrush(brush)
# Obtain the sub-rectangle within rect that will contain the text
qdata = option.fontMetrics.elidedText(text, self._elide, rect.width())
text_rect = painter.boundingRect(rect, self._alignment, qdata)
# Intersect text_rect with rect: text_rect must be contained within rect
text_rect &= rect
painter.drawText(text_rect, self._alignment, qdata)
return text_rect
[docs] def paintPicture(self, display_data, painter, rect):
"""
Paint image into a subsection of the supplied rectangle.
:param display_data: object used to generate the image
:type display_data: `DISPLAY_DATA_CLASS`
:param painter: The painter that is drawing in the table
:type painter: `QtGui.QPainter`
:param rect: The rectangle in which the image should be painted
:type rect: `QtCore.QRect`
"""
# Skip painting image if there is no room
if rect.height() <= 1:
return
pic = self.getPicture(display_data)
swidgets.draw_picture_into_rect(painter, pic, rect)
def _getCacheableArgs(self, *args, **kwargs):
"""
Given the display data returned by the table, produce appropriate
arguments for `getPicture` and return them. By default, simply returns
a tuple containing only `display_data`, but can be overridden for
subclasses if necessary.
:return: arguments necessary for calling `getPicture()`
"""
raise NotImplementedError
[docs] def getPicture(self, *args, **kwargs) -> QtGui.QPicture:
"""
Outward-facing API for accessing a picture.
This method should not be overridden in subclasses; instead,
`_getCacheableArgs()` and `_getPicture()` should be.
"""
cacheable_args = self._getCacheableArgs(*args, **kwargs)
return self._getCachedPicture(*cacheable_args)
def _getCachedPicture(self, *cacheable_args):
"""
Return the `QtGui.QPicture` instance to be displayed by the delegate.
A simple wrapper for the `_getPicture` method that uses the
`functools.lru_cache` to improve performance. As such, any arguments
for this method (and therefore also `_getPicture`) should be hashable
and should uniquely identify the desired picture.
Note that this method has an `lru_cache` decorator applied to it during
`__init__()` so that the cache data is stored on the instance rather
than the class.
:return: the picture to be displayed by this delegate
:rtype: `QtGui.QPicture`
"""
return self._getPicture(*cacheable_args)
def _getPicture(self, *cacheable_args):
"""
Return the `QtGui.QPicture` instance to be displayed by the delegate.
Must be implemented in a subclass. Should only be accessed via the
`getPicture` method, which is decorated with `functools.lru_cache`.
See `getPicture` documentation for restrictions on the arguments that
this method can accept.
:return: the picture to be displayed by this delegate
:rtype: `QtGui.QPicture`
"""
raise NotImplementedError
[docs] def getContentRect(self, rect):
"""
Given the rectangle representing the total area in the table cell,
return a (potentially) smaller rectangle that remains after the portion
of the rectangle dedicated to padding is removed.
:param rect: the rectangle corresponding to the full table cell area
:type rect: `QtCore.QRect`
:return: the rectangle corresponding to the subset of the table area
devoted to the cell contents (full cell minus padding area, if any)
:rtype: `QtCore.QRect`
"""
pad_width = rect.width() * self.padding_factor
pad_height = rect.height() * self.padding_factor
return rect.adjusted(pad_width, pad_height, -pad_width, -pad_height)
[docs] def clearImageCache(self):
"""
Clear the image cache. This function can be called from a subclass if
the `functools.lru_cache` decorator is applied to `getPicture`.
"""
self._getCachedPicture.cache_clear()
[docs]class ChmMolDelegate(PictureDelegate):
DISPLAY_DATA_CLASS = canvas2d.ChmMol
def _getCacheableArgs(self, chmmol):
"""
Transform a `canvas2d.ChmMol` object into a more hash-friendly object.
While the `ChmMol` alone is necessary for generating the image for this
delegate, the `HashableChmMol` object ensures that changes to the
`ChmMol` are reflected in its hash value, preventing the `getPicture()`
cache from returning outdated images if the `ChmMol` has changed
(PANEL-13897).
:param chmmol: a ChmMol object
:type chmmol: canvas2d.ChmMol
:return: a tuple containing a single hashable chmmol object
:rtype: tuple(HashableChmMol)
"""
return HashableChmMol(chmmol),
def _getPicture(self, hashable_chmmol):
"""
Returns a rendering of the `ChmMol` contained in the supplied
`HashableChmMol` object. While only the `chmmol` attribute of the object
is used to generate the image, the object's hash value reflects
important changes to the `ChmMol` object to ensure that the cache does
not make an incorrect match.
:param hashable_chmmol: a hashable ChmMol
:type hashable_chmmol: HashableChmMol
:return: a rendering of the supplied chmmol instance
:rtype: QtGui.QPicture
"""
# On failure, may return a QPicture containing the text "Failed to
# render". May also raise an exception.
chmmol = hashable_chmmol.chmmol
return structure2d.get_qpicture_protected(self.renderer, chmmol, False)
[docs]class StructureDelegate(PictureDelegate):
"""
Delegate used to display a 2D image of a small molecule.
"""
DISPLAY_DATA_CLASS = structure.Structure
def _getCacheableArgs(self, st):
"""
Given a structure, produce arguments necessary to pass to `getPicture`
to produce a cached picture. This must be done because the
`structure.Structure` object is not hashable, which is a necessary
requirement to use the `functools.lru_cache` decorator.
:param st: the structure used to generate a picture
:type st: `structure.Structure`
:return: a tuple containing
1. the structure handle
2. a second tuple with the structure's Cartesian coordinate
information
:rtype: 2-`tuple` of (`int`, `tuple`)
"""
st_tuple = structure_tuple(st)
smiles = analyze.generate_smiles(st)
return st.handle, st_tuple, smiles
def _getPicture(self, st_handle, st_tuple, smiles):
"""
Returns a 2D picture of the chemical structure with handle `st_handle`.
:param st_handle: handle of the structure to be rendered.
:type st_handle: `int`
:param st_tuple: a `tuple` of two tuples describing a structure: The
first contains the atom types. The second contains the
Cartesian coordinates of the atoms. This argument is
ignored within the body of the function, and is used
to uniquely identify different structures when using
the `functools.lru_cache`
:type st_tuple: A 2-`tuple` containing (`N`-tuple of `str`, `3*N`-tuple
of `float`)
:param smiles: SMILES string generate from the structure. It is
need to distinguish two structures that have
different stereochemistry.
:type smiles: str
:return: A 2D image of the supplied chemical structure
:rtype: `QtGui.QPicture`
"""
stereo = canvas2d.ChmMmctAdaptor.StereoFromAnnotationAndGeometry_Safe
chmmol = self.adaptor.create(st_handle, stereo)
# On failure, may return a QPicture containing the text "Failed to
# render". May also raise an exception.
return structure2d.get_qpicture_protected(self.renderer, chmmol)
[docs]class HashableRdMol:
"""
A `rdchem.Mol` wrapper that extracts data from the `rdchem.Mol` object and
uses it to form a useful hash value. This is necessary to ensure that a
`rdchem.Mol` instance is not accidentally matched with a totally different
`rdchem.Mol` because they happen to share the same memory location (at
different times).
"""
[docs] def __init__(self,
rdmol: Mol,
atom_idcs: Optional[Tuple[int]] = None,
color: Optional[Tuple[int]] = None,
atom_labels: Optional[List[str]] = None):
"""
:note: as of this writing, sketcher does not appear to support rendering
both custom atom coloring and atom labels.
:param rdmol: the molecule for which the 2D image will be rendered
:param atom_idcs: optionally, atom indices for `rdmol` which should be
colored. Will be ignored if `color` is not specified.
:param color: a (red, green, blue) tuple describing the color of the
atoms (optionally) specified by `atom_idcs`. Will be ignored if
`atom_idcs` is not specified.
:param atom_labels: optionally, text labels to annotate each atom. The
labeled atom is specified by the index of the label within the
list, so we must have `len(atom_labels) == rdmol.GetNumAtoms()`
"""
self.rdmol = rdmol
self.atom_idcs = None
self.color = None
if atom_idcs and color:
self.atom_idcs = tuple(atom_idcs)
self.color = color
self.atom_labels = atom_labels
self._smiles = Chem.MolToSmiles(rdmol)
def __hash__(self):
"""
Using SMILES to distinguish between different molecules.
Note: But SMILES doesn't distinguish between different conformers of the
same molecule.
"""
return hash(
(self._smiles, self.atom_idcs, self.color, self.atom_labels))
def __eq__(self, other):
if not isinstance(other, HashableRdMol):
return False
return hash(self) == hash(other)
[docs]class RdMolDelegate(PictureDelegate):
"""
Delegate that uses RDKit and sketcher to display a 2D image of a small
molecule.
Input `Mol` objects can use implicit or explicit hydrogens.
The delegate will attempt to align the input structures against a template
(arbitrarily chosen as the first `Mol` instance encountered by the
delegate after reset). In order for this to work, the input structures must
have some parts in common. Consequently, if the delegate is first used on
a group of similar molecules "A", it must be cleared prior to being used on
a second group of molecules "B" that may not be similar to the first.
Otherwise, it will attempt to align all group B molecules against a template
chosen from group A, which may not produce good results. The `clear()`
method can be used to accomplish this.
"""
DISPLAY_DATA_CLASS = Mol
[docs] def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.template_rdmol = None
[docs] def clear(self):
"""
Clear cached data, including the image cache and template `Mol`.
"""
self.template_rdmol = None
self.clearImageCache()
def _getCacheableArgs(
self,
rdmol: Mol,
atom_idcs: Optional[Tuple[int]] = None,
color: Optional[Tuple[int]] = None,
atom_labels: Optional[List[str]] = None) -> HashableRdMol:
"""
Transform a RDKit molecule object into a more hash-friendly object.
While the `rdmol` alone is necessary for generating the image for this
delegate, the `HashableRdMol` object ensures that changes to the `rdmol`
are reflected in its hash value, preventing the `getPicture()` cache
from returning outdated images if the `rdmol` has changed.
:note: as of this writing, sketcher does not appear to support rendering
both custom atom coloring and atom labels.
:param rdmol: the molecule for which the 2D image will be rendered
:param atom_idcs: optionally, atom indices for `rdmol` which should be
colored. Will be ignored if `color` is not specified.
:param color: a (red, green, blue) tuple describing the color of the
atoms (optionally) specified by `atom_idcs`. Will be ignored if
`atom_idcs` is not specified.
:param atom_labels: optionally, text labels to annotate each atom. The
labeled atom is specified by the index of the label within the
list, so we must have `len(atom_labels) == rdmol.GetNumAtoms()`
:return: a hashable object that stores all of the above data
"""
return HashableRdMol(rdmol,
atom_idcs=atom_idcs,
color=color,
atom_labels=atom_labels),
def _getPicture(self, hashable_rdmol):
"""
Generate a 2D rendering for `hashable_rdmol` using the sketcher
library. Molecules can have implicit or explicit hydrogens - hydrogen
coordinates will be ignored. 2D coordinates will be generated with
no explicit hydrogens, and hydrogens will be only rendered as “H”
labels on heavy atoms.
:param hashable_rdmol: hash-friendly RDKit molecule object
:type hashable_rdmol: HashableRdMol
:return: picture of the provided `HashableRdMol` object
:rtype: QtGui.QPicture
"""
# Sketcher requires explicit hydrogens (PYAPP-8490) but we need to
# generate coordinates without hydrogens, otherwise they can
# "get in the way" and cause suboptimal 2D coordinates (PYAPP-8522).
# So we generate coordinates for heavy atoms only, then add hydrogens:
mol_to_render = Chem.RemoveHs(hashable_rdmol.rdmol)
Chem.rdCoordGen.AddCoords(mol_to_render)
mol_to_render = Chem.AddHs(mol_to_render)
# TODO: If maestrohub.get2DRenderSettings().drawAllHs is True,
# generate coordinates *after* adding hydrogens.
if self.template_rdmol is None:
self.template_rdmol = Mol(mol_to_render)
# Align the template horizontally and the rest will follow
generate_min_height_coords(self.template_rdmol)
# Align the current and the template mol.
options = substructure.QueryOptions(tautomer_insensitive=True)
substructure.apply_substructure_coordinates(
mol=mol_to_render,
template_mol=self.template_rdmol,
options=options,
)
renderer = sketcher.Renderer()
# Skip clean up to preserve alignment
settings = sketcher.RendererSettings()
settings.skipCleanUp = True
renderer.loadSettings(settings)
renderer.loadStructure(mol_to_render)
color = QColor(*hashable_rdmol.color) if hashable_rdmol.color else None
atom_idcs = list(
hashable_rdmol.atom_idcs) if hashable_rdmol.atom_idcs else None
if atom_idcs and color:
renderer.colorAtoms(atom_idcs, color)
if hashable_rdmol.atom_labels:
renderer.labelAtoms(list(hashable_rdmol.atom_labels))
return renderer.getPicture()
[docs]class CheckboxDelegate(AbstractCustomDelegate):
"""
This delegate contains a clickable checkbox. The checkbox is only displayed
if the index contains data for the Qt.CheckStateRole role. If the
Qt.CheckStateRole role is None, then the standard delegate behavior will be
used. The index must flagged as enabled and editable for clicks to toggle
the model value, and clicks must occur on the checkbox itself. Clicks on
other areas of the cell will be handled as normal (i.e. selecting the cell).
Model.setData() will be called with the default role (i.e. Qt.EditRole) when
toggling the checkbox value. Note that this delegate uses a bi-state rather
than tri-state checkbox, so partially checked states are not allowed.
"""
DRAW_STYLE = QtWidgets.QStyle.CE_CheckBox
[docs] def __init__(self, parent=None):
super().__init__(parent)
self._cb = QtWidgets.QCheckBox()
[docs] def createEditor(self, parent, option, index):
"""
This delegate does not have a separate editor widget, so we return None.
All arguments are ignored, but are present for Qt compatibility.
"""
return None
[docs] def paint(self, painter, option, index):
"""
Paint the appropriate icon
:param painter: The painter being used to render the delegate
:type painter: `PyQt5.QtGui.QPainter`
:param option: The style options to use when painting
:type option: `PyQt5.QtWidgets.QStyleOptionViewItem`
:param index: The index being represented
:type index: `PyQt5.QtCore.QModelIndex`
"""
check_data = self._getCheckData(index)
if check_data is None:
super(CheckboxDelegate, self).paint(painter, option, index)
return
self.paintItemBackground(painter, option, index)
cb_style = self._getCbStyle(option, check_data, index.flags())
app_style = QtWidgets.QApplication.style()
app_style.drawControl(self.DRAW_STYLE, cb_style, painter)
def _getCheckData(self, index):
"""
Get the Qt.CheckStateRole data from the specified index
:param index: The index to get data from
:type index: `PyQt5.QtCore.QModelIndex`
:return: The checked data from `index`
:rtype: bool or NoneType
"""
check_data = index.data(Qt.CheckStateRole)
return check_data
def _getCbStyle(self, option, check_data, flags):
"""
Create a `PyQt5.QtWidgets.QStyleOptionButton` with the appropriate
options for the desired checkbox.
:param option: The style options to use when painting
:type option: `PyQt5.QtWidgets.QStyleOptionViewItem`
:param check_data: The data to be displayed in the checkbox.
:type check_data: bool
:param flags: The flags indicating the editable and enabled state of the
checkbox
:type flags: QtCore.Qt.ItemFlag
:return: The styled `PyQt5.QtWidgets.QStyleOptionButton`
:rtype: `PyQt5.QtWidgets.QStyleOptionButton`
"""
cb_style = QtWidgets.QStyleOptionButton()
cb_style.initFrom(self._cb)
cb_style.state |= QtWidgets.QStyle.State_Active
if (flags & Qt.ItemIsEnabled):
cb_style.state |= QtWidgets.QStyle.State_Enabled
if not (flags & Qt.ItemIsEditable):
cb_style.state |= QtWidgets.QStyle.State_ReadOnly
if check_data:
cb_style.state |= QtWidgets.QStyle.State_On
else:
cb_style.state |= QtWidgets.QStyle.State_Off
cb_style.rect = self._getCbRect(option)
return cb_style
def _getCbRect(self, option):
"""
Determine the rectangle for painting the checkbox
:param option: The style options to use when painting
:type option: `PyQt5.QtWidgets.QStyleOptionViewItem`
:return: The rectangle to paint the checkbox into
:rtype: `PyQt5.QtCore.QRect`
"""
cell_rect = option.rect
app_style = QtWidgets.QApplication.style()
cb_rect = app_style.subElementRect(app_style.SE_CheckBoxIndicator,
QtWidgets.QStyleOptionButton())
cb_x = cell_rect.x() + old_div(cell_rect.width(), 2) - old_div(
cb_rect.width(), 2)
cb_y = cell_rect.y() + old_div(cell_rect.height(), 2) - old_div(
cb_rect.height(), 2)
cb_topleft = QtCore.QPoint(cb_x, cb_y)
centered_cb_rect = QtCore.QRect(cb_topleft, cb_rect.size())
return centered_cb_rect
[docs] def editorEvent(self, event, model, option, index):
"""
Handle mouse clicks and key presses. Left clicking on the check box or
hitting the space bar will toggle the check box value. Left clicks
outside of the checkbox will be handled as normal (i.e. selecting the
cell).
:param event: The event that occurred
:type event: `PyQt5.QtCore.QEvent`
:param model: The data model
:type model: QAbstractTableModel
:param option: The style options for the cell
:type option: QStyleOptionViewItem
:param index: The index being edited
:type index: `PyQt5.QtCore.QModelIndex`
:return: True if the event was handled. False otherwise.
:rtype: bool
"""
check_data = self._getCheckData(index)
enabled = index.flags() & Qt.ItemIsEnabled
editable = index.flags() & Qt.ItemIsEditable
if check_data is None or not (enabled and editable):
return super(CheckboxDelegate,
self).editorEvent(event, model, option, index)
mouse_click = (event.type() == event.MouseButtonPress and
event.button() == Qt.LeftButton)
if mouse_click:
cb_rect = self._getCbRect(option)
click_loc = event.pos()
mouse_click = cb_rect.contains(click_loc)
key_press = (event.type() == event.KeyPress and
event.key() in (Qt.Key_Space, Qt.Key_Select))
if mouse_click or key_press:
model.setData(index, not check_data)
return True
else:
return super(CheckboxDelegate,
self).editorEvent(event, model, option, index)
[docs] def sizeHint(self, option=None, index=None):
"""
Provide a reasonable default size for the table cell. If no index is
provided, then option is not required and it is assumed that the cell
contains check data.
See Qt documentation for an explanation of arguments and return type
"""
if index is not None and self._getCheckData(index) is None:
return super(CheckboxDelegate, self).sizeHint(option, index)
app_style = QtWidgets.QApplication.style()
cb_rect = app_style.subElementRect(app_style.SE_CheckBoxIndicator,
QtWidgets.QStyleOptionButton())
size = cb_rect.size()
# Make sure there is a slight margin so the sides of the check box don't
# overlap with the table grid lines
size *= 1.5
return size
[docs]class ProgressBarDelegate(AbstractCustomDelegate):
"""
This delegate displays if the Qt.DisplayRows datat is an int or tuple;
standard delegate behavior is invoked otherwise.
Integer values are displayed as percentages on the progress bar. These must
be between -100 and 100.
The tuple should consist of (value, maximum), in which case the progress
text is shown as abs(value)/maximum.
A negative value flags the existence of an error and changes the color to
orange.
"""
DRAW_STYLE = QtWidgets.QStyle.CE_ProgressBar
[docs] def createEditor(self, parent, option, index):
"""
This delegate does not have a separate editor widget, so we return None.
All arguments are ignored, but are present for Qt compatibility.
"""
return None
[docs] def paint(self, painter, option, index):
"""
Paint the progress bar or use the default delegate
:param painter: The painter being used to render the delegate
:type painter: `QtGui.QPainter`
:param option: The style options to use when painting
:type option: `QtWidgets.QStyleOptionViewItem`
:param index: The index being represented
:type index: `QtCore.QModelIndex`
"""
data = index.data(Qt.DisplayRole)
if isinstance(data, int):
pb_style = self._getProgressBarStyle(option, data)
elif isinstance(data, tuple):
value, maximum = data
pb_style = self._getProgressBarStyle(option,
value,
maximum=maximum,
percent=False)
else:
super(ProgressBarDelegate, self).paint(painter, option, index)
return
self.paintItemBackground(painter, option, index)
app_style = QtWidgets.QApplication.style()
painter.save()
if sys.platform == "darwin":
# Paint the progress bar in the correct cell (PANEL-18688)
# May be fixed in Qt 6.0 https://bugreports.qt.io/browse/PYSIDE-1464
painter.translate(pb_style.rect.x(), pb_style.rect.y())
app_style.drawControl(self.DRAW_STYLE, pb_style, painter)
painter.restore()
def _getProgressBarStyle(self, option, progress, maximum=100, percent=True):
"""
Create a `QtWidgets.QStyleOptionButton` with the appropriate
options for the desired progress bar.
:param option: The style options to use when painting
:type option: `QtWidgets.QStyleOptionViewItem`
:param progress: The value -100..100 for the progress bar, negative value
flags error state
:type progress: int
:return: The styled `QtWidgets.QStyleOptionButton`
:rtype: `QtWidgets.QStyleOptionButton`
"""
style = QtWidgets.QStyleOptionProgressBar()
style.initFrom(option.widget)
style.minimum = 0
style.maximum = maximum
color = QtGui.QColor("#C78A3B") if progress < 0 else QtCore.Qt.green
for group in (QtGui.QPalette.Active, QtGui.QPalette.Inactive):
for role in (QtGui.QPalette.Window, QtGui.QPalette.Highlight):
style.palette.setColor(group, role, color)
style.progress = abs(progress)
if percent:
style.text = "{}%".format(style.progress)
else:
style.text = "{}/{}".format(style.progress, maximum)
style.textVisible = True
style.rect = option.rect
return style
[docs] def sizeHint(self, option=None, index=None):
"""
Provide a reasonable default size for the table cell. If no index is
provided, then option is not required and it is assumed that the cell
contains progress bar data.
See Qt documentation for an explanation of arguments and return type
"""
if index is not None and self._getProgressBarData(index) is None:
return super(ProgressBarDelegate, self).sizeHint(option, index)
return option.rect
[docs]class LinkDelegate(PushButtonDelegate):
"""
A delegate containing a clickable text. Text will be
taken from the Qt.DisplayRole data. When clicked, this delegate
will emit a `clicked` signal with either:
- If `role` is None, a `QtCore.QModelIndex` for the cell that was clicked.
- Otherwise, the `role` data for the cell that was clicked.
When optional link role is provided data with that is checked to
determine whether link should be enabled. When link role is None
link is always enabled. When link is disabled clicked signal is not
emitted.
:cvar DISABLED_STYLE: style sheet that sets button text to standard disabled
link color
:vartype DISABLED_STYLE: str
:cvar ENABLED_STYLE: style sheet that sets button text to "Maestro blue" to
match standard link appearance
:vartype ENABLED_STYLE: str
:ivar _cursor_over_delegate: whether the cursor is over the delgate
:vartype _cursor_over_delegate: bool
:ivar _cursor_override_active: whether the "pointing hand" cursor override
is currently in effect
:vartype _cursor_override_active: bool
"""
DISABLED_STYLE = f'QPushButton {{color: {LightModeColors.LINK_DISABLED}}}'
ENABLED_STYLE = f'QPushButton {{color: {LightModeColors.LINK}}}'
[docs] def __init__(self, view, role=None, link_role=None):
"""
:param view: The view that this delegate will be added to. Note that
mouse tracking will be enabled in the view.
:type view: QtWidgets.QTableView
:param role: The role to emit data for when a button is clicked. If not
given, the index that was clicked will be emitted instead. This value
may be specified after instantiation using `setRole`.
:type role: int or NoneType
:param link_role: The role to check data that determines whether link
should be enabled. (Note: may need to use a role different from
Qt.DisplayRole if the link should use the disabled stylesheet)
:type link_role: int or NoneType
"""
super().__init__(view, role)
self._link_role = link_role
self._cursor_over_delegate = False
self._cursor_override_active = False
[docs] def indexIsEnabled(self, index):
"""
:return: whether the specified index should have an enabled link
:rtype: bool
"""
if self._link_role is None:
return True
data = index.data(self._link_role)
return not any((data is False, data is None))
def _getButtonStyle(self, option, index, btn_txt=None):
"""
Create a QtWidgets.QStyleOptionButton with the appropriate options.
:param option: The style options to use when painting
:type option: QtWidgets.QStyleOptionViewItem
:param index: The index that the button will be painted on
:type index: QtCore.QModelIndex
:param btn_txt: The text to be displayed in the button. It not given,
will be retrieved from index.
:param btn_txt: str
:return: The styled QtWidgets.QStyleOptionButton
:rtype: QtWidgets.QStyleOptionButton
"""
if btn_txt is None:
btn_txt = index.data()
btn_style = QtWidgets.QStyleOptionButton()
if self.indexIsEnabled(index):
self._button.setStyleSheet(self.ENABLED_STYLE)
else:
self._button.setStyleSheet(self.DISABLED_STYLE)
btn_style.palette = self._button.palette()
btn_style.features = QtWidgets.QStyleOptionButton.ButtonFeature.Flat
btn_style.text = btn_txt
btn_style.rect = self._getButtonRect(btn_txt, btn_style, option.rect,
index)
return btn_style
def _buttonClicked(self, index):
"""
Emit the button clicked signal with the appropriate data.
:param index: The index to emit the signal for.
:type index: QtCore.QModelIndex
"""
if self.indexIsEnabled(index):
super()._buttonClicked(index)
[docs] def paint(self, painter, option, index):
"""
Temporarily updates the painter font to be bold to match standard link
appearance.
"""
link_enabled = self.indexIsEnabled(index)
link_font = painter.font()
link_font.setBold(True)
painter.save()
painter.setFont(link_font)
super().paint(painter, option, index)
painter.restore()
[docs] def eventFilter(self, viewport, event):
"""
Catch all events in order to apply the correct appearance to the cursor
based on its position.
:param viewport: The view's viewport. Not used, but present for
compatibility with Qt's event filtering.
:type viewport: QtWidgets.QWidget
:param event: The event to filter
:type event: QtCore.QEvent
:return: True if the event was handled and does not need to be passed to
the viewport. False otherwise. We want all events to be passed to
the viewport, so we always return False.
:rtype: bool
"""
result = super().eventFilter(viewport, event)
if event.type() == event.Leave or self._mouse_rc is None:
# The cursor has left the table area or is over an area that does
# not contain a cell
self._setCursorOverrideActive(False)
return result
if event.type() != event.MouseMove:
return result
view = self.parent()
model = view.model()
index = model.index(*self._mouse_rc)
delegate = view.itemDelegate(index)
if isinstance(delegate, LinkDelegate) and self.indexIsEnabled(index):
self._setCursorOverrideActive(True)
else:
self._setCursorOverrideActive(False)
return result
def _setCursorOverrideActive(self, active):
"""
Activate or deactivate the "pointing hand" cursor override if possible.
:param active: whether to attempt to activate or deactivate the cursor
override
:type active: bool
"""
if self._cursor_over_delegate == active:
return
self._cursor_over_delegate = active
app = QtWidgets.QApplication.instance()
cursor_is_overridden = app.overrideCursor() is not None
if active and not cursor_is_overridden:
# Only override cursor if it is not already overridden (e.g. with
# a "waiting" cursor)
app.setOverrideCursor(QtGui.QCursor(Qt.PointingHandCursor))
self._cursor_override_active = True
elif not active and self._cursor_override_active:
# Only restore the cursor if it was overridden by this delegate in
# the first place
app.restoreOverrideCursor()
self._cursor_override_active = False
[docs]def structure_tuple(st):
"""
Produce a hashable `tuple` to identify a structure by its Cartesian
coordinates and atom types.
:param st: a structure instance
:type st: `structure.Structure`
:return: two tuples containing a subset of the data from the structure
instance. The first contains the atom types; The second contains
the Cartesian coordinates of the atoms.
:rtype: tuple(tuple(str), tuple(float))
"""
type_names = tuple(a.atom_type_name for a in st.atom)
xyz = st.getXYZ()
xyz_values = tuple(float(xyz_val) for xyz_val in np.nditer(xyz))
return type_names, xyz_values