"""
Embeddable 2D Viewer widget.
For main panel, see mmshare/python/scripts/two_d_viewer_gui.py
Copyright Schrodinger, LLC. All rights reserved.
"""
import html
import math
import os
import shutil
import sys
# Import the module created from the QtDesigner *.ui file:
from schrodinger.ui import two_d_viewer_ui
from rdkit import Chem
from rdkit import Geometry
from rdkit.Chem import rdCoordGen
from rdkit.Chem import rdFMCS
from rdkit.Chem import rdmolops
from rdkit.Chem.MolStandardize import rdMolStandardize
import schrodinger
from schrodinger import project
from schrodinger import structure
from schrodinger.application.msv.gui import msv_rc # noqa: F401, for protein icon
from schrodinger.infra import mmproj
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtPrintSupport # For QPrinter
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.thirdparty import rdkit_adapter
from schrodinger import adapter
from schrodinger.ui import maestro_ui
from schrodinger.ui import sketcher
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import propertyselector
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import table
from schrodinger.ui.qt import table_helper
from schrodinger.ui.qt import utils as qtutils
from schrodinger.ui.qt.appframework2 import af2
from schrodinger.ui.qt.messagebox import show_warning
from schrodinger.ui.qt.utils import wait_cursor
from schrodinger.ui.qt.utils import get_view_item_options
from schrodinger.utils import fileutils
from schrodinger.utils import log
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import utils as qt_utils
try:
from schrodinger.application.canvas.packages import canvassharedguiSWIG
except ImportError:
canvassharedguiSWIG = None
maestro = schrodinger.get_maestro()
# Check whether SCHRODINGER_PYTHON_DEBUG is set for debugging:
DEBUG = (log.get_environ_log_level() <= log.DEBUG)
QSAR_PREDICTION_PROP = 'r_user_Predicted_Value'
# Image size export (HTML, PDF, and PNG):
EXPORT_IMAGE_HEIGHT = 300
EXPORT_IMAGE_WIDTH = 400
# How much to pad the outside/border of 2D structure images:
PADDING_FACTOR = 0.04
# When exporting images "unscaled" (using the size that rdkit uses for
# rendering the QPictures), sale the images down a little, so that each bond
# length is 60 pixels (0.2 inches at 300dpi) instead of 100 pixels.
EXPORT_NOSCALE_SCALE = 0.6
PDF_PAGE_MARGIN = 10
PNG_SINGLE_PAGE_EXPORT_THRESHOLD = 100
# Entry ID for this cell (or Structure handle if running outside of Maestro)
ENTRY_ID_ROLE = Qt.UserRole + 100
# Cell object:
CELL_ROLE = Qt.UserRole + 101
# model index (int)
MODEL_INDEX_ROLE = Qt.UserRole + 103
PROP_BACKGROUND_COLOR = QtGui.QColor(220, 220, 220)
PROP_BACKGROUND_COLOR_HEX = PROP_BACKGROUND_COLOR.name() # "#RRGGBB" string
PROP_LINE_COLOR = QtGui.QColor('#bababa')
TEXT_COLOR = QtGui.QColor(0, 0, 0)
MCS_HIGHLIGHT_COLOR = QtGui.QColor('#ff2eff')
REF_HIGHLIGHT_COLOR = QtGui.QColor('#2e96ff')
# 2 possible texts for the show/hide actions button:
MORE_ACTIONS_TEXT = 'More actions'
HIDE_ACTIONS_TEXT = 'Hide actions'
GUI_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), 'two_d_viewer_icons'))
[docs]def get_icon(path):
return QtGui.QIcon(os.path.join(GUI_DIR, path))
[docs]def st_to_rdmol_without_hydrogens(st):
"""
Create a sanitized RDMol from Structure. Used for MCS calculation only.
Hydrogens are exlucded to make the calculation faster, and to prevent
hangs.
NOTE: Atom indices will be different from the original structure, because
explicit hydrogens will be stripped (and if any hydrogen has lower atom
index than a heavy atom, heavy atoms will get renumbered).
:param st: Structure to convert.
:type st: structure.Structure
:return: RDKit molecule.
:rtype: Chem.Mol
"""
# implicitH speeds this up significantly, especially for complicated
# molecules, on which MCS calculation can hang. We do custom
# sanitation here in order to handle molecules with bad valences,
# yet still be able to properly handle aromatic bonds.
mol = rdkit_adapter.to_rdkit(st, sanitize=False, implicitH=True)
# sanitize everything except properties:
rdmolops.SanitizeMol(mol,
rdmolops.SANITIZE_ALL ^ rdmolops.SANITIZE_PROPERTIES)
return mol
[docs]def copy_coords_of_heavy_atoms(mol_noHs, mol_Hs):
"""
Copy coordinates of heavy atoms from the first structure to the second.
Sketcher prefers a molecule with explicit Hs in order to accurately reproduce
the tautomeric state of a molecule
:param mol_noHs: RDKit template (with implicit Hs) to copy heavy atom coordinates from
:type mol_noHs: Chem.Mol
:param mol_Hs: RDKit molecule to update.
:type mol_Hs: Chem.Mol
"""
conf_Hs = mol_Hs.GetConformer()
conf_noHs = mol_noHs.GetConformer()
sdgr_to_rdk = {
a.GetProp(adapter.SCHRODINGER_INDEX): a.GetIdx()
for a in mol_Hs.GetAtoms()
}
for atom_noHs in mol_noHs.GetAtoms():
st_idx_noHs = atom_noHs.GetProp(adapter.SCHRODINGER_INDEX)
position = conf_noHs.GetAtomPosition(atom_noHs.GetIdx())
conf_Hs.SetAtomPosition(sdgr_to_rdk[st_idx_noHs], position)
[docs]def get_mcs_atoms(rdmol1, rdmol2):
"""
Find the maximum common structure between the given structures,
and return atom lists for each molecule that match the MCS. Lists
are 0-indexed, because they are then passed on to rdkit. Input structures
should not have explicit Hs, to make the calculation faster and
avoid hangs on certen molecules
:param rdmol1: First molecule
:type rdmol1: Chem.Mol
:param rdmol2: Second molecule
:type rdmol2: Chem.Mol
:return: (list of MCS atoms in rdmol1, list of MCS atoms in rdmol2)
:rtype: (list, list)
"""
mols = [rdmol1, rdmol2]
res = rdFMCS.FindMCS(mols,
ringMatchesRingOnly=True,
completeRingsOnly=False,
timeout=1)
mcs_smarts = res.smartsString
if mcs_smarts == '':
if res.canceled:
# Time out; smartsString will be set to the largest common
# substructure found so far, if any.
raise RuntimeError('Timed out')
else:
raise RuntimeError('No MCS found')
# Assemble list of atoms in template and this entry that represent
# the core
mcs_mol = Chem.MolFromSmarts(mcs_smarts)
matches = rdmol1.GetSubstructMatches(mcs_mol)
tmatches = rdmol2.GetSubstructMatches(mcs_mol)
core_atoms1 = list(matches[0])
core_atoms2 = list(tmatches[0])
# TODO if multiple matches, consider using the match that is closer to
# the middle of the molecule (as rendered in 2D).
return core_atoms1, core_atoms2
[docs]def kpls_color_from_value(value):
"""
Calculate color corresponding to value for kpls annotations. Blue is minimum and red is maximum
"""
saturation = 0.1
# Interpolate color between blue (min) and red (max).
interp = 0.0
absv = math.fabs(value)
if absv < saturation:
interp = 255 * (saturation - absv) / saturation
g = interp
if value >= 0.0:
r = 255
b = interp
else:
r = interp
b = 255
return QtGui.QColor(r, g, b)
[docs]def property_string(atom, property_name):
value = atom.property.get(property_name)
if value is None:
return ""
if type(value) == type(1.0):
return f'{value:.1f}'
else:
return str(value)
[docs]def generate_pic_with_text(text):
"""
Create a QPicture with the text painted on it.
:param text: Text to draw in the picture.
:type text: str
:return: The QPicture with the text.
:rtype: QtGui.QPicture.
"""
pic = QtGui.QPicture()
pic.setBoundingRect(QtCore.QRect(0, 0, 150, 100))
painter = QtGui.QPainter(pic)
painter.setFont(QtGui.QFont("Arial", 15))
painter.drawText(pic.boundingRect(), Qt.AlignVCenter, text)
painter.end()
return pic
[docs]class CtPropsDialog(basewidgets.BaseDialog):
"""
Dialog for letting the user select which CT-level properties to show.
"""
ctPropertiesChanged = QtCore.pyqtSignal(list, bool, bool)
[docs] def initSetOptions(self):
super().initSetOptions()
self.help_topic = "PROJECT_MENU_2D_VIEWER_PROPERTIES_DB"
self.std_btn_specs = {
self.StdBtn.Ok: self.accept,
self.StdBtn.Cancel: self.reject
}
[docs] def __init__(self, viewer, all_props, selected_props, display_names,
use_title_caps):
super().__init__(viewer)
prop_objects = []
available_datanames = []
for prop in all_props: # all_props is a set
obj = structure.PropertyName(prop)
prop_objects.append(obj)
available_datanames.append(prop)
limited_selected_props = []
for prop in selected_props:
if prop in available_datanames:
prop = structure.PropertyName(prop)
limited_selected_props.append(prop)
self.setWindowTitle("2D Viewer - Select Properties")
prop_sel_widget = QtWidgets.QWidget()
self.property_selector = propertyselector.PropertySelector(
prop_sel_widget,
multi=True,
presort=False,
show_aux_listbox=True,
move_to_aux=True,
show_family_menu=True,
show_alpha_toggle=True,
show_filter_field=True,
)
self.property_selector.setProperties(prop_objects,
limited_selected_props)
self.main_layout.addWidget(prop_sel_widget)
self.display_property_names_box = QtWidgets.QCheckBox(prop_sel_widget)
self.display_property_names_box.setText('Display property names')
self.display_property_names_box.setChecked(display_names)
self.use_title_caps_cb = QtWidgets.QCheckBox(prop_sel_widget)
self.use_title_caps_cb.setChecked(use_title_caps)
self.use_title_caps_cb.setText('Use title caps')
checkboxes_layout = QtWidgets.QHBoxLayout()
checkboxes_layout.addWidget(self.display_property_names_box)
checkboxes_layout.addWidget(self.use_title_caps_cb)
checkboxes_layout.addStretch()
self.main_layout.addLayout(checkboxes_layout)
[docs] def accept(self):
show_names = self.display_property_names_box.isChecked()
title_caps = self.use_title_caps_cb.isChecked()
show_props = self.property_selector.getSelected()
prop_list = [prop.dataName() for prop in show_props]
self.ctPropertiesChanged.emit(prop_list, show_names, title_caps)
super().accept()
[docs]class AtomPropertyDialog(basewidgets.BaseDialog):
"""
Dialog for letting the user select which atom-level property to show.
"""
# Value will be property string, or None:
atomPropertyChanged = QtCore.pyqtSignal(object)
[docs] def initSetOptions(self):
super().initSetOptions()
self.std_btn_specs = {
self.StdBtn.Ok: self.accept,
self.StdBtn.Cancel: self.reject
}
[docs] def __init__(self, viewer, annotate_property):
self.viewer = viewer
super().__init__(viewer)
self.setWindowTitle("Choose Atom Property")
self.label = QtWidgets.QLabel("Label atoms with property:")
self.main_layout.addWidget(self.label)
self.property_selector = propertyselector.PropertySelectorList(self)
self.main_layout.addWidget(self.property_selector)
self.property_selector.setProperties(viewer.all_atom_props)
if annotate_property is not None:
self.property_selector.selectProperty(annotate_property)
[docs] def accept(self):
selected = self.property_selector.getSelected()
if selected:
self.atomPropertyChanged.emit(selected[0])
else:
self.atomPropertyChanged.emit(None)
super().accept()
[docs]class Cell:
"""
The idea is to dynamically generate an instance whenever the model
requests this data.
"""
[docs] def __init__(self, st, has_protein, entry_id):
"""
:param st: Structure to show in the cell (ligand only, no protein)
:type st: `structure.Structure`
:param has_protein: Whether to show the "protein" icon in the cell.
:type has_protein: bool
:param entry_id: Entry ID (when in Maestro) or Structure handle.
:type entry_id: int
"""
# Store ligand atoms only:
self.st = st
self.neut_st = None # will be generated later
self.has_protein = has_protein
self.entry_id = entry_id
# If aligned, (ref RDkit 2D conformer, ref core atoms, core atoms):
self.alignment_info = None
self.is_reference = False
self.rotate_degrees = 0
self.flip_vertical = False
self.flip_horizontal = False
self.atom_factors = [] # KPLS atomic factors
# These 2 rectangles are used for mapping coordinates from mouse
# pointer to coordinates in to the QPicture:
self.pic_bounds_rect = None # QPicture bounds rect
# QRect where picture was drawn into (for mapping coordinates):
self.pic_dest_rect = None # QRect where picture was drawn to
[docs] def getProjectRow(self):
"""
Return the ProjectRow instance for this entry.
Returns None if the entry is no longer in the Project Table.
"""
pt = maestro.project_table_get()
return pt.getRow(self.entry_id)
[docs] def isIncluded(self):
if self.entry_id:
project_row = self.getProjectRow()
if project_row is None:
# Entry is no longer in project table.
# TODO return a new state representing "disabled"
return False
return bool(project_row.in_workspace)
else:
return False
[docs] def getIncludedState(self):
"""
Will return one of the following:
NOT_IN_WORKSPACE, IN_WORKSPACE, LOCKED_IN_WORKSPACE
"""
if self.entry_id:
project_row = self.getProjectRow()
if project_row is None:
# Entry is no longer in project table.
# TODO return a new state representing "disabled"
return False
return project_row.in_workspace
else:
return False
[docs] def clearAlignment(self):
self.alignment_info = None
self.is_reference = False
self.clearTransformations()
[docs] def generateNeutralStateIfNeeded(self, uncharger):
"""
Generate the neutral state of the structure, if one is still missing.
"""
if not self.neut_st:
try:
mol = rdkit_adapter.to_rdkit(self.st,
sanitize=True,
implicitH=True)
neut_mol = uncharger.uncharge(mol)
neut_mol.UpdatePropertyCache(strict=False)
self.neut_st = rdkit_adapter.from_rdkit(neut_mol)
except Exception as e:
# We just skip neutralization step on error.
print("WARNING: Failed to neutralize CT: %s" % self.entry_id)
print(" Exception: %s" % e)
self.neut_st = self.st
[docs]class TwoDTableModel(table.ViewerModel):
"""
Custom model for 2D Viewer table
The idea is for this model to dynamically change as the project table
changes.
"""
# TODO consider creating a proxy model class, which would take a
# a linear (1-column) model and "map" it onto a multi-column proxy model,
# for connecting to the view.
[docs] def __init__(self, parent, *args):
table.ViewerModel.__init__(self, *args)
# List of Cell objects:
self._cells = []
self._num_cols = 3
self.viewer = parent
self._last_mouse_clicked_index = None
[docs] def removeReference(self):
for cell in self._cells:
cell.clearAlignment()
[docs] def clearReferenceHighlighting(self):
"""
Clears reference ligand highlighting.
Note: cells are still aligned to previous alignment, if any
"""
for cell in self._cells:
cell.is_reference = False
[docs] def setReferenceToIndex(self, ref_i):
any_reset = False
for i, cell in enumerate(self._cells):
if cell.alignment_info:
any_reset = True
cell.alignment_info = None
cell.is_reference = (i == ref_i)
if any_reset:
self.viewer.status("Reference changed - all orientations reset")
[docs] def alignCellsToReference(self, ref_i):
"""
Set MCS/alignment reference to the given cell index, and align
all cells to it.
"""
self.setReferenceToIndex(ref_i)
ref_cell = self.getCellFromI(ref_i)
ref_st = self.viewer.getStructToRender(ref_cell)
try:
ref_rdmol = st_to_rdmol_without_hydrogens(ref_st)
except Exception as err:
show_warning(
parent=None,
text=
f'Could not align structures because reference structure failed to render:\n{err}'
)
return
cg_params = rdCoordGen.CoordGenParams()
cg_params.treatNonterminalBondsToMetalAsZOBs = True
# Generate a 2D conformer for the reference ligand:
rdCoordGen.AddCoords(ref_rdmol, cg_params)
ref_conf = ref_rdmol.GetConformer()
num_cells = len(self._cells)
progress = QtWidgets.QProgressDialog("Aligning 2D coordinates...",
"Cancel", 1, num_cells,
self.viewer)
# Show dialog right away, in modal form:
progress.setWindowModality(Qt.WindowModal)
progress.show()
for i, cell in enumerate(self._cells, start=1):
if progress.wasCanceled():
break
st = self.viewer.getStructToRender(cell)
try:
rdmol = st_to_rdmol_without_hydrogens(st)
except:
progress.setValue(i)
continue
try:
ref_core_atoms, core_atoms = get_mcs_atoms(ref_rdmol, rdmol)
except RuntimeError as err:
print('Error calculating MCS: %s' % err)
cell.clearAlignment()
else:
# TODO consider saving SCHRODINGER_INDEX values instead
cell.alignment_info = (ref_conf, ref_core_atoms, core_atoms)
cell.copyTransformations(ref_cell)
# NOTE: We are storing references to the Chem.rdchem.Conformer
# to use it as a template when generating 2D images.
# TODO refactor to generate 2D coordintes for all ligands here,
# and cache them, for smoother scrolling of 2D Viewer window.
progress.setValue(i)
# Ensure that progress dialog is closed:
progress.close()
[docs] def rowCount(self, parent=None):
"""
Returns number of rows needed to display the data
"""
count = ((len(self._cells) - 1) // self._num_cols) + 1
return count
[docs] def getRowColumnFromI(self, i):
row = (i // self._num_cols)
col = i - (row * self._num_cols)
return (row, col)
[docs] def getIndexFromI(self, i):
assert i is not None
(row, col) = self.getRowColumnFromI(i)
return self.index(row, col)
[docs] def columnCount(self, parent=None):
return self._num_cols
[docs] def flags(self, index):
default_flags = super().flags(index)
if index.isValid():
# All non-placeholder cells can be dragged
return Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled | default_flags
else:
# Place-holders can be also dragged into.
return Qt.ItemIsDropEnabled | default_flags
[docs] def data(self, index, role=Qt.DisplayRole):
"""
Use getCellFromIndex() instead
"""
if not index.isValid():
return None
if role == ENTRY_ID_ROLE:
i = self._getIFromIndex(index)
try:
return self._cells[i].entry_id
except IndexError:
# Place-holder cell at the end
return None
if role == CELL_ROLE:
cell = self._getCellFromIndex(index)
return cell
elif role == MODEL_INDEX_ROLE:
return self._getIFromIndex(index)
# Fix for Ev:126134
elif role == QtCore.Qt.DisplayRole:
return ""
return None
def _getCellFromIndex(self, index):
"""
Return a Cell object for the given QModelIndex instance.
"""
i = self._getIFromIndex(index)
try:
return self._cells[i]
except IndexError:
return None
def _getIFromIndex(self, index):
"""
Return the cell number for the given QModelIndex object.
"""
i = index.row() * self._num_cols + index.column()
return i
[docs] def numCells(self):
return len(self._cells)
[docs] def getCellFromI(self, i):
try:
return self._cells[i]
except IndexError:
return None
[docs] def getIFromCell(self, cell):
try:
i = self._cells.index(cell)
except ValueError:
return None
return i
[docs] def setCells(self, cells):
"""
Set the internal data list to the specified list of Cell objects.
"""
self.modelAboutToBeReset.emit()
self._cells = cells
self.modelReset.emit()
[docs] def getEntryIds(self):
"""
Return a list of entry IDs that are currently in the table.
"""
return [cell.entry_id for cell in self._cells]
[docs] def getTitles(self):
"""
Return a list of titles, for all structures. Each title is prepended
with the row number of this entry (if in Maestro) or structure index
(if outside of Maestro).
"""
if self.viewer.enable_maestro_features:
pt = maestro.project_table_get()
row_iter = (pt[cell.entry_id] for cell in self._cells)
titles = [
'%i: %s' % (row.row_number, row.title) for row in row_iter
]
else:
st_iter = (cell.st for cell in self._cells)
titles = [
'%i: %s' % (i, st.title)
for i, st in enumerate(st_iter, start=1)
]
return titles
[docs] def removeAllRows(self):
self.setCells([])
[docs] def setColumns(self, new_num_cols):
self.modelAboutToBeReset.emit()
self._num_cols = new_num_cols
self.modelReset.emit()
[docs] @table_helper.model_reset_method
def removeIsFromTable(self, remove_is):
"""
Remove cells with specified i's (cell index ints) from the table.
"""
self._cells = [
cell for i, cell in enumerate(self._cells) if i not in remove_is
]
[docs] def moveCell(self, old_index, new_index):
"""
Move the given cell from old index (x,y position) to new index.
"""
prev_i = self._getIFromIndex(old_index)
new_i = self._getIFromIndex(new_index)
self.modelAboutToBeReset.emit()
cell = self._cells.pop(prev_i)
self._cells.insert(new_i, cell)
self.modelReset.emit()
# NOTE: No need to preserve selection, as the act of starting a
# drag event clears any selection.
def _getSingleIncludedI(self):
"""
If only one of the cells is included in the Workspace, return the
index for it. Otherwise returns None.
"""
included_eids = maestro.get_included_entry_ids()
included_i = None
for i, cell in enumerate(self._cells):
if cell.entry_id in included_eids:
if included_i is not None:
# More than one included
return None
included_i = i
return included_i
[docs] def handleInclusonToggled(self, index):
"""
Called when inclusion toggle within a cell is clicked.
:param index: Index of the clicked cell
:type index: QModelIndex
"""
application = QtWidgets.QApplication.instance()
key_modifiers = application.keyboardModifiers()
ctrl_down = bool(key_modifiers & Qt.ControlModifier)
shift_down = bool(key_modifiers & Qt.ShiftModifier)
self._handleInclusionToggled(index, ctrl_down, shift_down)
def _handleInclusionToggled(self, index, ctrl_down, shift_down):
"""
:param index: Index of the clicked cell
:type index: QModelIndex
:param ctrl_down: Whether control/command key was held
:type ctrl_down: bool
:param shift_down: Whether shift key was held
:type shift_down: bool
"""
cell = index.data(CELL_ROLE)
pt = maestro.project_table_get()
save_clicked_cell = True
if ctrl_down:
project_row = cell.getProjectRow()
if cell.isIncluded():
# Exclude this cell only:
project_row.in_workspace = False
# Do not consider this click for next shift-click:
save_clicked_cell = False
else:
# Include this cell (not affecting others):
project_row.in_workspace = True
elif shift_down:
self.handleShiftClick(index)
else:
# No shift or control; include this entry and exclude others:
cell.getProjectRow().includeOnly()
# Unless de-selecting with control key held, use this click for next
# shift-click event:
if save_clicked_cell:
self._last_mouse_clicked_index = QtCore.QPersistentModelIndex(index)
else:
self._last_mouse_clicked_index = None
[docs] def handleShiftClick(self, index):
"""
:param index: Index of the clicked cell
:type index: QModelIndex
"""
last_idx = self._last_mouse_clicked_index
if last_idx and last_idx != index and last_idx.isValid():
# If previously clicked entry is still present, start from it:
start_i = self._getIFromIndex(last_idx)
else:
# If there is exactly one cell included, select range between
# it and clicked cell:
start_i = self._getSingleIncludedI()
clicked_i = self._getIFromIndex(index)
if start_i is not None:
# Include a range of cells:
first_i, last_i = sorted((start_i, clicked_i))
include_is = range(first_i, last_i + 1)
else:
# Include only this single cell:
include_is = [clicked_i]
include_eids = [self.getCellFromI(i).entry_id for i in include_is]
pt = maestro.project_table_get()
pt.includeRows(include_eids, exclude_others=False)
[docs]class TwoDTableView(table.DataViewerTable):
[docs] def __init__(self, model, parent):
table.DataViewerTable.__init__(self,
model,
parent,
aspect_ratio=False,
fill='columns',
gang='none',
resizable='none',
fit_view='none',
vscrollbar='default',
hscrollbar='default',
enable_copy=False)
self.viewer = parent
self.setDragEnabled(True)
self.setDropIndicatorShown(True)
self.setDragDropMode(QtWidgets.QTableView.DragDrop)
# While dragging of a cell, gets set to the QModelIndex of the cell:
self.dragging_cell_index = None
[docs] def columnWidth(self, colnum):
table_width = self.viewport().width()
col_width, col_remainder = divmod(table_width,
self.model().columnCount())
if colnum == self.model().columnCount() - 1:
col_width += col_remainder
return col_width
[docs] def rowHeight(self, rownum):
"""
Returns the height of the specified row (same for all rows).
This method is used by the view.
"""
col_width = self.columnWidth(0)
num_props = len(self.viewer.getPropertiesToShow())
return col_width + (self.viewer.font_height * num_props)
[docs] def keyPressEvent(self, event):
"""
Will get called when a key is pressed
"""
# Ev:109449
key = event.key()
if key == Qt.Key_P or key == Qt.Key_N:
# Ev:112447 pass this event to the panel's keyPressEvent()
event.ignore()
elif key == Qt.Key_PageUp:
self.verticalScrollBar().triggerAction(
QtWidgets.QAbstractSlider.SliderPageStepSub)
elif key == Qt.Key_PageDown:
self.verticalScrollBar().triggerAction(
QtWidgets.QAbstractSlider.SliderPageStepAdd)
else:
return table.DataViewerTable.keyPressEvent(self, event)
[docs] def resizeEvent(self, event):
"""
Called when the viewport changes size. Resizes the rows to keep
images square. Uses view's rowHeight() method.
"""
ret = super().resizeEvent(event)
self.resizeRowsToContents()
return ret
[docs] def dragEnterEvent(self, event):
self.dragging_cell_index = self.indexAt(event.pos())
return super().dragEnterEvent(event)
[docs] def dropEvent(self, event):
"""
Don't allow drops to the right of the right-most column. See Qt
documentation for additional method documentation.
"""
assert self.dragging_cell_index
replace_index = self.indexAt(event.pos())
if self.dragging_cell_index == replace_index:
return
self.model().moveCell(self.dragging_cell_index, replace_index)
self.dragging_cell_index = None
return super().dropEvent(event)
[docs]class TwoDTableDelegate(table.GenericViewerDelegate):
[docs] def __init__(self, viewer, tableview, tablemodel):
table.GenericViewerDelegate.__init__(self, tableview, tablemodel)
self.viewer = viewer
self.view = tableview
[docs] def paint(self, painter, option, index):
# Draw the background according to the selection state on Windows
# (Ev:124240):
QtWidgets.QItemDelegate.paint(self, painter, option, index)
painter.setBrush(QtGui.QColor(0, 0, 0))
entry_id = index.data(ENTRY_ID_ROLE)
if not entry_id:
# Skip painting if this is a placeholder cell.
return
cell = index.data(CELL_ROLE)
pic = self.viewer.generatePicFromCell(cell)
self.viewer.paintOneCell(painter, cell, pic, option.rect, False)
[docs]class TwoDViewer(QtWidgets.QFrame):
"""
Embeddable 2D Viewer, that contains:
1) Main table, with a vertical scroll bar
2) "Change view" pull-down menu
3) Text "Drag cells to reorder. Right-click for more options."
4) Reference pull-down menu with "Align All" button
5) Transformation options
6) Generate report options
:ivar enable_maestro_features: Enables maestro connected features, inclusion in project table
and import functionality
:vartype enable_maestro_features: bool
"""
enable_maestro_features: bool = bool(maestro)
PROGRESS_STRUCT_COUNT = 1000
[docs] def __init__(self, parent=None):
super().__init__(parent)
if parent and parent.window().__class__.__name__ == 'TwoDViewerPanel':
self.panel = parent.window()
else:
self.panel = None
self.ui = two_d_viewer_ui.Ui_Form()
self.ui.setupUi(self)
self.populateViewMenu()
self.ui.show_hide_btn.toggled.connect(self.onShowHideActionsToggled)
self.ui.actions_frame.setStyleSheet("background-color:#D9D9D9;")
self.populateSaveExportMenu()
self.setupPanel()
self.kpls_vis = None
self.display_property_names = True
self.use_title_caps = False
# Atom-level property to use for annotation. None means element symbol
self.annotate_property = None
self.max_atoms = 200
# FIXME use maestro.get_command_option( "prefer", "2dmaxatoms" )
# Linked to max_scale. Small values mean benzenes appear smaller:
# Ev:79334 So that small molecules do not have extra long bonds:
self.max_scale_factor = 0.2
self.all_props = set()
self.selected_props = []
# Atom-level properties that can be used for annotation
self.all_atom_props = []
self.precisions = {}
if self.enable_maestro_features:
self.synchronizePreferencesFromMaestro()
maestro.command_callback_add(self.commandCallback)
[docs] def onShowHideActionsToggled(self, checked):
"""
Show or hide the actions section.
"""
self.ui.actions_frame.setVisible(checked)
self.ui.actions_frame.adjustSize()
if checked:
btn_text = HIDE_ACTIONS_TEXT
else:
btn_text = MORE_ACTIONS_TEXT
self.ui.show_hide_btn.setText(btn_text)
self.repaint()
[docs] def neutralizeToggled(self, on):
"""
Called when the "Show Neutral Form" menu item is toggled.
"""
if on:
self.generateNeutralizedStates()
self.refreshTable()
[docs] @wait_cursor
def generateNeutralizedStates(self):
"""
For each loaded structure, generate the neuralized form, if not
already present.
"""
uncharger = rdMolStandardize.Uncharger(canonicalOrder=True)
for cell in self.getAllCells():
cell.generateNeutralStateIfNeeded(uncharger)
[docs] def linkSelectionAndInclusionToggled(self, on):
if on:
self.includeSelectedCellsEntries()
[docs] def displayPropsDialog(self):
"""
Open CT properties dialog box.
"""
dialog = CtPropsDialog(self, self.all_props, self.selected_props,
self.display_property_names, self.use_title_caps)
dialog.ctPropertiesChanged.connect(self.showPropNames)
dialog.exec()
[docs] def showPropNames(self, selected_props, show_names, title_caps):
self.selected_props = selected_props
self.display_property_names = show_names
self.use_title_caps = title_caps
if selected_props:
self.show_entry_props_action.setChecked(True)
self.propsChanged()
[docs] def getPropertiesToShow(self):
"""
Return a list of (property data name, whether to include property name)
for each property that should be rendered below the 2D image.
"""
if self.show_entry_props_action.isChecked():
return self.selected_props
else:
return []
[docs] def atomPropDialog(self):
"""
Open atom annotation dialog box.
"""
dialog = AtomPropertyDialog(self, self.annotate_property)
dialog.atomPropertyChanged.connect(self.atomPropChanged)
dialog.exec()
[docs] def atomPropChanged(self, prop):
self.annotate_property = prop
if prop:
self.show_atom_anns_action.setChecked(True)
self.propsChanged()
[docs] def openDisplayPreferences(self):
"""
Open the Maestro's preference dialog, to the image display tab.
"""
maestro.command("showpanel prefer:2dstructure")
[docs] def columnActionSelected(self, item_action):
num_cols = item_action.data()
self.setNumColumns(num_cols)
item_action.setChecked(True)
[docs] def synchronizePreferencesFromMaestro(self):
"""
Update the 2D Viewer's preferences to the Maestro's preferences.
"""
# Ev:104410
# NOTE: Please put in an effort to make this method as fast as possible.
self.max_atoms = int(maestro.get_command_option("prefer", "2dmaxatoms"))
self.max_scale_factor = float(
maestro.get_command_option("prefer", "2dmaxscalefactor"))
self.setWaitCursor()
# Re-draw visible cells:
self.refreshTable()
self.restoreCursor()
[docs] @qt_utils.remove_wait_cursor
def info(self, text, preferences=None, key=""):
messagebox.show_info(parent=self, text=text, save_response_key=key)
[docs] @qt_utils.remove_wait_cursor
def warning(self, text, preferences=None, key=""):
messagebox.show_warning(parent=self, text=text, save_response_key=key)
[docs] def setWaitCursor(self):
self.setCursor(QtGui.QCursor(Qt.WaitCursor))
[docs] def restoreCursor(self):
self.setCursor(QtGui.QCursor(Qt.ArrowCursor))
[docs] def status(self, text=''):
"""
Set the status label to the specified text.
"""
if self.panel:
self.panel.status(text)
[docs] def commandCallback(self, command):
"""
Called by Maestro when a command is issued, and is used to update
the 2D Viewer preferences when Maestro's preferences change.
"""
# Ev:104410
s = command.split()
if s[0] == "prefer":
option = s[1].split('=')[0]
if option in [
"2dmaxatoms", "2dmaxscalefactor", "2dfontsize",
"2dbondlinewidth", "2dhashspacing", "2dbondspacing",
"2dusecolor", "2dshowimplicithydrogens", "2dlabelallcarbons"
]:
self.synchronizePreferencesFromMaestro()
[docs] @af2.maestro_callback.workspace_changed
def workspaceChanged(self, what_changed):
if self.ignore_project_update:
# This change was triggered by 2D Viewer itself
return
if what_changed not in (maestro.WORKSPACE_CHANGED_EVERYTHING,
maestro.WORKSPACE_CHANGED_APPEND,
maestro.WORKSPACE_CHANGED_CONNECTIVITY):
return
if not self.ui.auto_update_cb.isChecked():
return
included_eids = maestro.get_included_entry_ids()
if not included_eids:
return
pt = maestro.project_table_get()
maestro.project_table_synchronize()
for i, cell in enumerate(self.getAllCells()):
eid = cell.entry_id
if eid in included_eids:
st = pt[eid].getStructure(workspace_sync=False)
new_cell = self.generateCellForEntry(eid, st)
self.table_model._cells[i] = new_cell
# Redraw the visible cells:
self.table_view.viewport().repaint()
[docs] @wait_cursor
def copyToClipboard(self, cell):
pic = self.generatePicFromCell(cell)
img = self.getImageFromPic(pic)
application = QtWidgets.QApplication.instance()
clipboard = application.clipboard()
clipboard.setImage(img)
[docs] def setupPanel(self):
""" Setup the GUI """
self.included_pixmap = QtGui.QPixmap(
":/pt_gui_dir/icons/entry-active-included.png")
self.excluded_pixmap = QtGui.QPixmap(
":/pt_gui_dir/icons/entry-active-excluded.png")
self.included_fixed_pixmap = QtGui.QPixmap(
":/pt_gui_dir/icons/entry-active-fixed-in-project.png")
self.protein_present_pixmap = QtGui.QPixmap(
":/msv/icons/struct_included_light.png")
self.ui.reference_combo.currentIndexChanged.connect(
self.referenceChanged)
self.ui.align_all_btn.clicked.connect(self.alignAllToReference)
self.ui.previous_button.clicked.connect(self.previousStructure)
self.ui.next_button.clicked.connect(self.nextStructure)
self.ui.rotate_left_btn.clicked.connect(self.rotateLeft)
self.ui.rotate_right_btn.clicked.connect(self.rotateRight)
self.ui.flip_horizontal_btn.clicked.connect(self.flipHorizontal)
self.ui.flip_vertical_btn.clicked.connect(self.flipVertical)
self.ui.rotate_left_btn.setIcon(get_icon('rotate-counterclock-15.png'))
self.ui.rotate_right_btn.setIcon(get_icon('rotate-clockwise-15.png'))
self.ui.flip_horizontal_btn.setIcon(get_icon('flip-horizontal.png'))
self.ui.flip_vertical_btn.setIcon(get_icon('flip-vertical.png'))
# Create zoom widget:
self.zoom_view = ZoomWidget(self)
self.zoom_view.installEventFilter(self)
self.zoom_view.setContextMenuPolicy(Qt.CustomContextMenu)
self.zoom_view.customContextMenuRequested.connect(
self.zoomContextMenuRequested)
self.ui.stacked_widget.addWidget(self.zoom_view)
self.zoom_i = None
self.table_model = TwoDTableModel(self)
self.table_view = TwoDTableView(self.table_model, self)
self.table_view.setStyleSheet('gridline-color: #808080;')
view_options = get_view_item_options(self.table_view)
view_font = view_options.font
self.font_height = QtGui.QFontMetrics(view_font).height()
self.table_view.setContextMenuPolicy(Qt.CustomContextMenu)
self.table_view.customContextMenuRequested.connect(
self.gridContextMenuRequested)
self.table_view.viewport().installEventFilter(self)
self.ui.stacked_widget.addWidget(self.table_view)
self.ui.stacked_widget.setCurrentWidget(self.table_view)
self.table_delegate = TwoDTableDelegate(self, self.table_view,
self.table_model)
self.table_delegate.setPaintWait(True)
self.table_view.setItemDelegate(self.table_delegate)
self.table_view.selectionModel().selectionChanged.connect(
self.tableSelectionChanged)
self.table_view.doubleClicked.connect(self.cellDoubleClicked)
# Hide the headers:
vh = self.table_view.verticalHeader()
vh.setVisible(False)
hh = self.table_view.horizontalHeader()
hh.setVisible(False)
# Disable the prev/next buttons:
self.updatePreviousAndNextButtons()
# Initialize self.table_view FIXME
# multi select, don't show title bar
# Add 3 columns
# TODO use flag_context_manager for these in the future
self.ignore_selection_changed = False
self.ignore_project_update = False
[docs] def eventFilter(self, obj, event):
if event.type() not in (QtCore.QEvent.MouseButtonPress,
QtCore.QEvent.ToolTip):
return False
if obj == self.zoom_view:
localx = event.pos().x()
localy = event.pos().y()
item_rect = self.zoom_view.rect()
assert self.zoom_i is not None
cell = self.getZoomCell()
index = self.table_model.getIndexFromI(self.zoom_i)
elif obj == self.table_view.viewport():
index = self.table_view.indexAt(event.pos())
if not index.isValid():
return False
cell = index.data(CELL_ROLE)
if cell is None:
# Place-holder cell, ignore events
return False
item_rect = self.table_view.visualRect(index)
localx = event.pos().x() - item_rect.left()
localy = event.pos().y() - item_rect.top()
else:
return False
if event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == Qt.LeftButton:
pass_on_event = self.cellClicked(index, localx, localy)
if obj == self.zoom_view:
self.zoom_view.repaint()
return not pass_on_event
elif event.button() == Qt.RightButton:
# If this cell is already selected, then prevent the view from
# de-selecting the other cells before showing the contextual
# menu. If this cell is NOT selected, then we do want the view
# to select it and deselect the other cells first.
ignore_event = cell.entry_id in self.getSelectedEntryIds()
return ignore_event
if event.type() == QtCore.QEvent.ToolTip:
help_event = QtGui.QHelpEvent(event)
# Figure out whether the cursor is in the "included" icon:
if self.enable_maestro_features:
incmin = self.getIncludeToggleOffset()
incmax = incmin + 12 # included icon is 12x12
if incmin <= localx <= incmax and incmin <= localy <= incmax:
state = cell.getIncludedState()
if state == project.NOT_IN_WORKSPACE:
tooltip_text = "Excluded from Workspace"
elif state == project.LOCKED_IN_WORKSPACE:
tooltip_text = "Locked in Workspace"
elif state == project.IN_WORKSPACE:
tooltip_text = "Included in Workspace"
QtWidgets.QToolTip.showText(help_event.globalPos(),
tooltip_text)
return True # handled event
property_matrix = self.calculatePropertiesCoordinates(
cell, item_rect)
# Figure out which property the cursor is in:
for username, value, top, bottom in property_matrix:
if localy > top and localy < bottom:
tooltip_text = "%s: %s" % (username, value)
QtWidgets.QToolTip.showText(help_event.globalPos(),
tooltip_text)
return True # handled event
# Figure out whether cursor is over an atom that has an annotation:
if obj == self.zoom_view and cell.atom_factors:
pos = QtCore.QPoint(localx, localy)
tooltip = self.getTooltipForPosition(pos, self.getZoomCell())
if tooltip:
QtWidgets.QToolTip.showText(help_event.globalPos(), tooltip)
return True # handled event
# Hide the previous tool-tip:
QtWidgets.QToolTip.hideText()
event.ignore()
return True
return False
[docs] def getIncludeToggleOffset(self):
if self.inZoomMode():
return 10
else:
return 3
[docs] def paintOneCell(self, painter, cell, pic, cell_rect, exporting):
"""
Paint the given QPicture of the 2D structure, plus properties and
inclusion icon to the specified painter.
NOTE: For large structures, pic will contain rendering of text
"Too many atoms to display: X" instead of the 2D image.
If the structure failed to render, the pic will contain only
white background (as of 9/11/19).
"""
global_left = cell_rect.left()
global_top = cell_rect.top()
# Go to cell's reference frame:
painter.translate(global_left, global_top)
cell_right = cell_rect.width()
cell_bottom = cell_rect.height()
props_height = (self.font_height * len(self.getPropertiesToShow()))
if pic is not None:
# If not scrolling
pic_w = cell_right
pic_h = cell_bottom - props_height
dest_rect = QtCore.QRect(0, 0, pic_w, pic_h)
cell.pic_dest_rect = swidgets.draw_picture_into_rect(
painter, pic, dest_rect, self.max_scale_factor, PADDING_FACTOR)
cell.pic_bounds_rect = pic.boundingRect()
toffset = self.getIncludeToggleOffset()
# Create include/exclude toggle at top-left corner of the canvas:
if self.enable_maestro_features and not exporting:
state = cell.getIncludedState()
if state == project.NOT_IN_WORKSPACE:
pixmap = self.excluded_pixmap
elif state == project.LOCKED_IN_WORKSPACE:
pixmap = self.included_fixed_pixmap
elif state == project.IN_WORKSPACE:
pixmap = self.included_pixmap
# NOTE: Icons are 24x24 in size, so using 12x12 here renders
# them without scaling on Retina displays.
painter.drawPixmap(toffset, toffset, 12, 12, pixmap)
if cell.has_protein:
# This icon is 100x100 in size, scale to 20x20 (40x40 on retina).
# Any smaller size makes this icon look worse.
size = 20
x = cell_right - toffset - size
painter.drawPixmap(x, toffset, size, size,
self.protein_present_pixmap)
# Draw the properties:
property_matrix = self.calculatePropertiesCoordinates(cell, cell_rect)
# First fill background of the properties in gray. This needs to be
# done first, so that horizontal lines are drawn on top.
if property_matrix:
_, _, props_top, _ = property_matrix[0]
_, _, _, props_bottom = property_matrix[-1]
props_height = props_bottom - props_top
bg_rect = QtCore.QRect(0, props_top, cell_right, props_height)
painter.fillRect(bg_rect, PROP_BACKGROUND_COLOR)
# Figure out which property the cursor is in:
for prop_uname, value, prop_top, prop_bottom in property_matrix:
prop_height = prop_bottom - prop_top
# Draw a horizontal line at the bottom edge of the prop rect:
painter.setPen(PROP_LINE_COLOR)
painter.drawLine(0, prop_bottom, cell_right, prop_bottom)
painter.drawLine(cell_right, prop_top, cell_right, prop_bottom)
middle = cell_right / 2
value_text = str(value)
painter.setPen(TEXT_COLOR)
margin = 4 # property margin
if self.display_property_names:
if self.use_title_caps:
prop_uname = prop_uname.upper()
width = middle - (2.0 * margin)
painter.drawText(margin, prop_top, width, prop_height, 0,
prop_uname)
painter.drawText(middle + margin, prop_top, width,
prop_height, 0, value_text)
else:
width = cell_right - (2.0 * margin)
painter.drawText(margin, prop_top, width, prop_height, 0,
value_text)
# Go back to the global reference frame:
painter.translate(-global_left, -global_top)
[docs] def getPropsToShowForCell(self, cell):
"""
Return a list of (property name, value) for properties to be reported
for the given cell.
"""
if not self.show_entry_props_action.isChecked():
return []
props = []
for i, dataname in enumerate(self.selected_props):
username = structure.PropertyName(dataname).userName()
value = cell.st.property.get(dataname, 'None')
if value != 'None' and self.precisions and dataname.startswith(
"r_"):
precision = self.precisions[dataname]
value = round(value, precision)
props.append((username, value))
return props
[docs] def calculatePropertiesCoordinates(self, cell, rect):
"""
Returns a list of (propname, value, top_y, bottom_y) for all
properties that are to be displayed to the cell.
This information is then used for drawing properties and for displaying
tool tips.
# Ev:91560
"""
props = self.getPropsToShowForCell(cell)
prop_height = self.font_height
cell_height = rect.height()
# Go to cell's reference frame:
props_height = (prop_height * len(props))
top_of_props = cell_height - props_height
property_matrix = []
# Draw the property texts:
for i, (prop_uname, value) in enumerate(props):
prop_top = top_of_props + (i * prop_height)
prop_bottom = prop_top + prop_height
property_matrix.append((prop_uname, value, prop_top, prop_bottom))
return property_matrix
[docs] def keyPressEvent(self, event):
"""
Will get called when a key is pressed
"""
key = event.key()
if key == Qt.Key_M:
self.mark()
elif key == Qt.Key_P:
self.previousStructure()
elif key == Qt.Key_N:
self.nextStructure()
elif key in (Qt.Key_PageUp, Qt.Key_Left) and self.inZoomMode():
self.previousStructure()
elif key in (Qt.Key_PageDown, Qt.Key_Right) and self.inZoomMode():
self.nextStructure()
else:
super().keyPressEvent(event)
[docs] def mark(self, ignored=None):
cell_to_mark = None
if self.inZoomMode():
cell_to_mark = self.getZoomCell()
else:
included_cells = []
num_rows = self.table_model.rowCount()
num_cols = self.table_model.columnCount()
for rowi in range(num_rows):
for coli in range(num_cols):
index = self.table_model.index(rowi, coli)
cell = index.data(CELL_ROLE)
if cell:
if cell.isIncluded():
included_cells.append(cell)
if len(included_cells) == 0:
return
elif len(included_cells) == 1:
cell_to_mark = included_cells[0]
else: # More than one included
num = self.numSelectedCells()
if num == 1:
cell_to_mark = self.getSelectedCell()
if cell_to_mark:
row = cell_to_mark.getProjectRow()
if row is None:
self.warning('Entry is no longer in the Project Table.')
return
prev = row['b_m_pose_viewer_tag']
# If was None or False, will set to True:
row['b_m_pose_viewer_tag'] = not prev
pt = maestro.project_table_get()
pt.update()
[docs] def setNumColumns(self, new_num_cols):
old_num = self.table_model.columnCount()
if new_num_cols == old_num:
return
self.setWaitCursor()
self.status("Changing the number of columns...")
self.ignore_selection_changed = True
# Save old selection:
selected_is = []
for i in self.iterateOverSelectedIs():
selected_is.append(i)
# Change the number of columns:
self.table_model.setColumns(new_num_cols)
# Show the table and hide place-holder:
self.table_view.resizeRowsToContents()
self.table_view.resizeColumnsToContents()
# Restore the selection:
if selected_is:
index_to_scroll_to = None
new_selection = QtCore.QItemSelection()
for i in selected_is:
index = self.table_model.getIndexFromI(i)
new_selection.select(index, index)
if not index_to_scroll_to:
index_to_scroll_to = index
selection_model = self.table_view.selectionModel()
selection_model.select(new_selection,
QtCore.QItemSelectionModel.ClearAndSelect)
if index_to_scroll_to is not None:
self.table_view.scrollTo(index_to_scroll_to)
self.ignore_selection_changed = False
self.restoreCursor()
self.status()
[docs] def cellDoubleClicked(self, item):
# Ev:72224 Don't zoom when double clicking a cell:
return
[docs] def toggleZoomView(self):
if self.inZoomMode():
self.exitZoomView()
else:
self.enterZoomView()
[docs] def previousStructure(self):
if not self.ui.previous_button.isEnabled():
return # Ev:107285
index = self.table_view.selectedIndexes()[0]
i = index.data(MODEL_INDEX_ROLE)
if i != 0:
# If this is not the first cell
self.selectOnlyCellI(i - 1)
[docs] def nextStructure(self):
if not self.ui.next_button.isEnabled():
return # Ev:107285
index = self.table_view.selectedIndexes()[0]
i = index.data(MODEL_INDEX_ROLE)
if i != (self.table_model.numCells() - 1):
# If this is not the last cell
self.selectOnlyCellI(i + 1)
[docs] def rotateLeft(self):
for cell in self.getAllCells():
if cell.flip_horizontal != cell.flip_vertical: # XOR
cell.rotate_degrees += 15.0
else:
cell.rotate_degrees -= 15.0
self.refreshTable()
[docs] def rotateRight(self):
for cell in self.getAllCells():
if cell.flip_horizontal != cell.flip_vertical: # XOR
cell.rotate_degrees -= 15.0
else:
cell.rotate_degrees += 15.0
self.refreshTable()
[docs] def flipHorizontal(self):
for cell in self.getAllCells():
cell.flip_horizontal = not cell.flip_horizontal
self.refreshTable()
[docs] def flipVertical(self):
for cell in self.getAllCells():
cell.flip_vertical = not cell.flip_vertical
self.refreshTable()
[docs] def selectOnlyCellIndex(self, index):
"""
Select only the cell with the given index
"""
i = index.data(MODEL_INDEX_ROLE)
self.selectOnlyCellI(i)
[docs] def selectOnlyCellI(self, i):
if self.inZoomMode():
self.zoom_i = i
# Whether in zoom view or not, change table selection:
selection_model = self.table_view.selectionModel()
index = self.table_model.getIndexFromI(i)
selection_model.select(index, QtCore.QItemSelectionModel.SelectCurrent)
# Scroll to new selection:
self.table_view.scrollTo(index)
i = index.data(MODEL_INDEX_ROLE)
# In case we reached first/last structure:
if i == 0:
self.ui.previous_button.setEnabled(False)
else:
self.ui.previous_button.setEnabled(True)
if i == self.table_model.numCells() - 1:
self.ui.next_button.setEnabled(False)
else:
self.ui.next_button.setEnabled(True)
if self.inZoomMode():
# Ev:87002 Must repaint at very end:
self.zoom_view.repaint()
# When in zoom view, show groups of the zoomed entry:
self.showZoomCellStatus()
else:
self.status()
[docs] def showZoomCellStatus(self):
"""
Show the group info in the status field.
"""
if not self.inZoomMode() or not maestro:
return
row = self.getZoomCell().getProjectRow()
if row is None:
# Entry is no longer in PT
self.status('No longer in Project Table')
return
group = row.group
if group is None:
self.status('Group: N/A')
else:
groups = [group.title]
parent_group = group.getParentGroup()
while parent_group is not None:
groups.insert(0, parent_group.title)
parent_group = parent_group.getParentGroup()
group_txt = ' > '.join(groups)
# TODO truncate from left
self.status('Group: %s' % group_txt)
[docs] def enterZoomView(self):
if self.table_model.numCells() == 0:
self.warning("No cell found to zoom into")
self.single_view_action.setChecked(False)
return
# Zoom into first selected cell, or if there is no selection, first
# cell of the table
selected_indices = self.table_view.selectedIndexes()
if selected_indices:
index = selected_indices[0]
else:
# No selection - view first item:
index = self.table_model.index(0, 0)
if len(selected_indices) != 1:
# Select the cell we are zooming into:
self.selectOnlyCellIndex(index)
self.zoom_i = index.data(MODEL_INDEX_ROLE)
self.ui.stacked_widget.setCurrentWidget(self.zoom_view)
self.grid_layout_action.setChecked(False)
self.single_view_action.setChecked(True)
self.updatePreviousAndNextButtons()
self.column_submenu.setEnabled(False)
self.ui.instruction_label.setText('Right-click for more options.')
self.ui.previous_button.show()
self.ui.next_button.show()
[docs] def exitZoomView(self):
self.ui.stacked_widget.setCurrentWidget(self.table_view)
self.zoom_i = None
self.single_view_action.setChecked(False)
self.grid_layout_action.setChecked(True)
self.column_submenu.setEnabled(True)
self.ui.instruction_label.setText(
'Drag cells to reorder. Right-click for more options.')
self.ui.previous_button.hide()
self.ui.next_button.hide()
[docs] def inZoomMode(self):
"""
Return True if currently in zoom (single structure) mode.
"""
return self.zoom_i != None
[docs] def getZoomCell(self):
"""
Return the Cell object for the currently zoomed cell.
"""
assert self.zoom_i is not None
return self.table_model.getCellFromI(self.zoom_i)
[docs] def tableSelectionChanged(self, selected, deselected):
"""
Called by Qt when table selection is changed
"""
if self.ignore_selection_changed:
# Do nothing if we updated the table ourselves
return
# Make sure that place-holder was not selected:
selection_model = self.table_view.selectionModel()
deselect_selection = QtCore.QItemSelection()
placeholders_found = False
last_row = self.table_model.rowCount() - 1
for index in self.table_view.selectionModel().selectedIndexes():
if index.row() == last_row:
cell = index.data(CELL_ROLE)
if cell is None:
# Deselect this place-holder cell:
deselect_selection.select(index, index)
placeholders_found = True
if placeholders_found:
# We do not want this to trigger a selection changed event:
self.ignore_selection_changed = True
# Invert selection of these cells:
selection_model.select(deselect_selection,
QtCore.QItemSelectionModel.Toggle)
self.ignore_selection_changed = False
if self.enable_maestro_features and self.link_sel_and_inc_action.isChecked(
):
# Select newly included entries
self.includeSelectedCellsEntries()
self.updatePreviousAndNextButtons()
self.updateInfoLabel()
[docs] def includeSelectedCellsEntries(self):
"""
Include all selected cell's entries in the Workspace, and exclude
any other entries.
"""
pt = maestro.project_table_get()
eids_included_in_pt = set(maestro.get_included_entry_ids())
eids_selected_in_2d_viewer = self.getSelectedEntryIds()
if eids_included_in_pt == set(eids_selected_in_2d_viewer):
return
num_selected = len(eids_selected_in_2d_viewer)
# Show a warning if including > 100 structures; but do not show the
# warning if just including a few more structures than before:
if num_selected > 100 and num_selected > len(eids_included_in_pt) + 10:
msg = "Do you really want to include %i entries in Workspace?" % num_selected
result = maestro.question(msg,
button1="Include entries",
button2="No")
if result == maestro.BUTTON2:
self.restoreCursor()
self.status()
return
with wait_cursor:
self.status("Including selected cell's entries...")
# Do not run projectUpdated() within this method:
self.ignore_project_update = True
pt.includeRows(eids_selected_in_2d_viewer, exclude_others=True)
# Find first entry that was just now included (and previously
# wasn't included) and scroll PT to show it, if it was previously
# out of view:
for eid in eids_selected_in_2d_viewer:
if not eid in eids_included_in_pt:
# Emit scrollToEntry signal to update HPT
maestrohub = maestro_ui.MaestroHub.instance()
maestrohub.scrollToEntryID.emit(int(eid))
# TODO consider moving this into Project.includeRows()
break
# Redraw 2D viewer:
self.table_view.viewport().update()
# Update the Project Table for changes to take effect:
self.ignore_project_update = False
self.status()
[docs] def updateInfoLabel(self):
"""
Update the label that shows how many structure are loaded, and how
many are selected.
"""
if not self.panel:
return
num_selected = len(self.table_view.selectionModel().selectedIndexes())
num_total = self.table_model.numCells()
text = '%i of %i structures selected' % (num_selected, num_total)
self.panel.ui.info_label.setText(text)
[docs] def showEntryIds(self, entry_ids_to_show):
"""
Populate the table with the given entry IDs.
"""
if self.inZoomMode():
# If in zoom view:
self.exitZoomView()
pt = maestro.project_table_get()
self.all_props = set(pt.getPropertyNames())
self.all_atom_props = set()
if entry_ids_to_show:
# Get a list of atom-level properties
first_eid = entry_ids_to_show[0]
st = pt[first_eid].getStructure()
self.all_atom_props.update(
st.getAtomPropertyNames(include_builtin=True))
previously_selected_eids = [
index.data(ENTRY_ID_ROLE)
for index in self.table_view.selectionModel().selectedIndexes()
]
num_cells = len(entry_ids_to_show)
pt = maestro.project_table_get()
maestro.project_table_synchronize()
select_i_list = []
def st_iterator():
for idx, eid in enumerate(entry_ids_to_show):
st = pt[eid].getStructure(workspace_sync=False)
yield st, eid
if eid in previously_selected_eids:
select_i_list.append(idx)
self.populateTableWithProgress(st_iterator(),
num_cells,
'Updating...',
disable_auto=True)
# TODO do not re-create Cell objects for entries that were previously
# in the panel already, but only for entries that haven't changed
# since the last update (is this even possible)?
# Ev:91575 Use property precisions:
self.calculatePropertyPrecisions()
self.updatePreviousAndNextButtons()
self.updateInfoLabel()
item_selection = QtCore.QItemSelection()
for i in select_i_list:
index = self.table_model.getIndexFromI(i)
item_selection.select(index, index)
self.table_view.selectionModel().select(
item_selection, QtCore.QItemSelectionModel.ClearAndSelect)
self.status()
[docs] def populateTableWithProgress(self,
st_iter,
count,
dialog_text,
disable_auto=False):
"""
Populate the table with the structure iterator and launch a
progress if there is a certain amount of structures.
:param st_iter: generator of structures and entry id
:type st_iter: Iterator[Structure, int]
:param count: total count of structures
:type count: int
:param dialog_text: progress text displayed in dialog
:type dialog_text: str
:param disable_auto: whether to disable the auto update checkbox
when user cancels import
:type disable_auto: bool
"""
progress = None
if count >= self.PROGRESS_STRUCT_COUNT:
progress = QtWidgets.QProgressDialog(dialog_text, "Cancel", 1,
count, self)
# Show dialog right away, in modal form:
progress.setWindowModality(Qt.WindowModal)
progress.show()
def progress_iter():
for st_num, item in enumerate(st_iter):
if progress.wasCanceled():
if disable_auto:
# Hide the dialog, and only load Cells that were created up to
# this point. Also make sure auto-update is disabled, as the
# user may otherwise get frustrated as 2D Viewer would cause
# delays in any future PT changes.
self.ui.auto_update_cb.setChecked(False)
break
progress.setValue(st_num)
yield item
progress.close()
if progress is not None:
self.populateTableFromStructures(progress_iter())
else:
self.populateTableFromStructures(st_iter)
[docs] def calculatePropertyPrecisions(self):
# NOTE: previous precisions are not cleared to avoid removing
# the property QSAR_PREDICTION_PROP, which is not present in the
# PT but is dynamically added to each structure. And it does not
# hurt to have extra items in self.precisions dict.
pth = maestro.project_table_get().handle
num_columns = mmproj.mmproj_table_get_column_total(pth, 1)
for column_index in range(1, num_columns + 1):
data_name = mmproj.mmproj_table_get_column_data_name(
pth, 1, column_index)
precision = mmproj.mmproj_table_get_column_display_precision(
pth, 1, column_index)
self.precisions[data_name] = precision
[docs] def populateTableFromStructures(self, st_iterator):
cells = []
for st, eid in st_iterator:
cell = self.generateCellForEntry(eid, st)
cells.append(cell)
self.table_model.setCells(cells)
if self.neutralize_action.isChecked():
self.generateNeutralizedStates()
self.updateReferenceMenu()
self.updateInfoLabel()
# Show the table and hide place-holder:
self.table_view.resizeRowsToContents()
self.table_view.resizeColumnsToContents()
[docs] def generateCellForEntry(self, eid, st):
"""
Update the cell for the specified entry (if loaded) to the latest
structure present in the PT.
"""
lig_st, has_protein = self._retrieveLigand(st)
return Cell(lig_st, has_protein, eid)
[docs] def alignAllToReference(self):
self.status() # Clear "reference changed" status
i = self.ui.reference_combo.currentIndex()
if i == 0 or i == -1:
# "None" or no items in combo menu:
self.table_model.removeReference()
else:
self.table_model.alignCellsToReference(i - 1)
self.refreshTable()
[docs] def referenceChanged(self, i):
ref_selected = (i > 0) # Not -1 (no items) or 0 ("None" item).
if ref_selected:
self.table_model.setReferenceToIndex(i - 1)
else:
self.table_model.clearReferenceHighlighting()
self.ui.align_all_btn.setEnabled(ref_selected)
self.refreshTable()
[docs] def setCellAsReference(self, cell):
"""
Called when "Set Structure as Reference" is selected from contextual
menu.
"""
i = self.table_model.getIFromCell(cell)
self.ui.reference_combo.setCurrentIndex(i + 1)
self.table_model.removeReference()
cell.is_reference = True
self.refreshTable()
[docs] def refreshTable(self):
"""
Redraw the table (if in grid view) or the
zoom widget (if in single structure view).
"""
if self.inZoomMode():
self.zoom_view.repaint()
else:
self.table_view.viewport().repaint()
[docs] def propsChanged(self):
self.update()
if self.inZoomMode():
return # FIXME
self.refreshTable()
[docs] @wait_cursor
def synchFromFile(self, infile):
"""
Initializes the 2D viewer from the input file.
"""
if self.inZoomMode():
# If in zoom view:
self.exitZoomView()
self.ignore_selection_changed = True
self.clearTable()
self.ignore_selection_changed = False
self.status("Reading structures from file...")
self.all_props = set()
self.all_atom_props = []
self._showSyncProgress(infile)
self.status()
return True
def _showSyncProgress(self, infile):
"""
Show the progress of loading structures if there is 1000 or more
structures.
"""
if infile:
st_count = structure.count_structures(infile)
self.populateTableWithProgress(self.readStructsFromFile(infile),
st_count,
dialog_text='Loading structures...')
[docs] def readStructsFromFile(self, infile):
"""
Generate structures from the input file.
:param infile: input file
"""
st_num = 0
for st in structure.StructureReader(infile):
st_num += 1
for prop in st.property:
self.all_props.add(prop)
if st_num == 1:
self.all_atom_props = st.getAtomPropertyNames(
include_builtin=True)
# Outside of Maestro, use CT handle as "entry ID":
yield st, st.handle
[docs] def quitPanel(self):
"""
It gets called when panel quits (not just gets hidden)
"""
if self.enable_maestro_features:
maestro.command_callback_remove(self.commandCallback)
[docs] def clearTable(self):
self.table_model.removeAllRows()
self.table_view.selectionModel().clearSelection()
self.updateInfoLabel()
[docs] def setDefaults(self):
"""
Reset the GUI
"""
self.kpls_vis = None
self.ui.actions_frame.hide()
self.ui.show_hide_btn.setChecked(False)
self.exitZoomView()
self.clearTable()
self.ui.reference_combo.clear()
self.ui.reference_combo.addItem('None')
self.highlight_mcs_action.setChecked(False)
self.png_scale_action.setChecked(True)
self.png_transparent_bg_action.setChecked(False)
# Reset the View combo menu:
self.column_actions[2].setChecked(True) # 3 columns
self.column_actions[2].trigger()
if self.enable_maestro_features:
self.link_sel_and_inc_action.setChecked(False)
self.neutralize_action.setChecked(False)
self.show_entry_props_action.setChecked(True)
self.selected_props = ['s_m_title']
self.display_property_names = True
self.use_title_caps = False
self.show_atom_anns_action.setChecked(False)
self.propsChanged()
self.status()
[docs] def getSelectedCell(self):
"""
Returns the selected cell.
Should only be used when only one is selected
Raises an ValueError if no cells are selected.
"""
try:
index = self.table_view.selectedIndexes()[0]
except IndexError:
raise ValueError("No cell is selected")
return index.data(CELL_ROLE)
[docs] def iterateOverSelectedIs(self):
"""
One by one, return index (to the model) of selected cells.
"""
for index in self.table_view.selectionModel().selectedIndexes():
yield index.data(MODEL_INDEX_ROLE)
[docs] def iterateOverSelectedCells(self):
"""
Iterator for all selected cells (sorted)
"""
for index in self.table_view.selectionModel().selectedIndexes():
yield index.data(CELL_ROLE)
[docs] def getSelectedEntryIds(self):
"""
Return a list of entry IDs for selected cells.
"""
return [c.entry_id for c in self.iterateOverSelectedCells()]
[docs] def getNumCells(self):
"""
Return the number of cells that is currently loaded into the panel.
"""
return self.table_model.numCells()
[docs] def getAllCells(self):
"""
Return the list of Cell objects stored in the model.
"""
return self.table_model._cells
[docs] def getStructToRender(self, cell):
"""
Return the Structure object for this cell. Protein atoms will
be excluded.
"""
if self.neutralize_action.isChecked():
return cell.neut_st
else:
return cell.st
[docs] def removeSelectedFromTable(self):
"""
Remove selected cells from the 2D Viewer table.
"""
selected_is = set(self.iterateOverSelectedIs())
self.table_model.removeIsFromTable(selected_is)
[docs] def cellClicked(self, index, x, y):
"""
Called for mouse clicks in the cell
"""
if not maestro:
# Ignore this event and pass it on
return True
maxoffset = self.getIncludeToggleOffset() + 14
toggle_clicked = (x < maxoffset) and (y < maxoffset)
if not toggle_clicked:
# Ignore this event and pass it on to view, to select/deselect
return True
# If we got here, then the INCLUDE/EXCLUDE toggle was clicked
self.setWaitCursor()
self.status("Updating inclusion...")
cell = index.data(CELL_ROLE)
project_row = cell.getProjectRow()
if project_row is None:
self.warning('Entry is no longer in the Project Table.')
return False
self.ignore_project_update = True
self.table_model.handleInclusonToggled(index)
self.ignore_project_update = False
# Re-draw the cell with the new icon:
self.table_view.viewport().update()
self.restoreCursor()
self.status()
return False # Do not use this event for select/deselect
[docs] def exportImages(self):
if self.getNumCells() == 0:
self.warning("No cells to export")
return
filters = 'PNG File (*.png);; PDF File (*.pdf);; HTML File (*.html)'
outfile = filedialog.get_save_file_name(
self,
"Save image file as:",
"", # initial path
filters,
)
if not outfile:
return
fileutils.force_remove(outfile)
ext = fileutils.splitext(outfile)[1]
if ext == '.png':
self.exportToPngFile(outfile)
elif ext == '.pdf':
self.exportToPdfFile(outfile)
elif ext == '.html':
self.exportToHtml(outfile)
else:
self.warning(f"Invalid file extension: {outfile}")
[docs] def exportStructures(self):
if self.getNumCells() == 0:
self.warning("No cells to export")
return
filters = '2D SD File (*.sdf);; 3D SD File with SMILES (*.sdf)'
# Create File Dialog so we can differentiate between 2d and 3d sdf
file_dlg = filedialog.FileDialog(
self,
"Save image file as:",
filter=filters,
)
file_dlg.setAcceptMode(filedialog.QFileDialog.AcceptSave)
file_dlg.setDefaultSuffix('.sdf')
if not file_dlg.exec():
return
outfiles = file_dlg.selectedFiles()
if len(outfiles) != 1:
return
outfile = str(outfiles[0])
fileutils.force_remove(outfile)
is_2d = file_dlg.selectedNameFilter().startswith('2D')
if is_2d:
self.exportTo2dSdFile(outfile)
else:
self.exportTo3dSdWithSmilesFile(outfile)
[docs] def exportToPdfFile(self, outfile):
self.status("Exporting...")
try:
self.generatePdf(outfile)
except RuntimeError as err:
self.warning(str(err))
self.status()
[docs] def exportToHtml(self, outfile):
self.status("Exporting...")
try:
self.generateHtml(outfile)
except RuntimeError as err:
self.warning(str(err))
self.status()
[docs] def exportToPngFile(self, outfile):
"""
Export all structures to one or several PNG files. Derive base name from
given file name.
"""
outfile = str(outfile) # Convert to Python string
basename = fileutils.splitext(outfile)[0]
st_count = self.getNumCells()
num_cols = self.table_model.columnCount()
st_num = 0
num_skipped = 0
canceled = False
single_file = self.png_single_grid.isChecked()
transparent = self.png_transparent_bg_action.isChecked()
if self.png_scale_action.isChecked():
# Use dimensions from QPicture
dimensions = None
else:
dimensions = (EXPORT_IMAGE_WIDTH, EXPORT_IMAGE_HEIGHT)
if single_file and st_count >= PNG_SINGLE_PAGE_EXPORT_THRESHOLD:
if not self.question(
'A single page displaying the entire grid will be very long. Continue anyway?'
):
return
rows = []
for i, cell in enumerate(self.getAllCells()):
if i % num_cols == 0:
rows.append([])
rows[-1].append(cell)
max_row = len(rows)
max_col = max(len(col) for col in rows)
progress = QtWidgets.QProgressDialog("Exporting structures...",
"Cancel", 1, st_count, self)
progress.setWindowModality(Qt.WindowModal)
progress.show()
imgs = []
for irow, cells in enumerate(rows):
for icol, cell in enumerate(cells):
st_num += 1
if progress.wasCanceled():
canceled = True
break # Exit loop
pic = self.generatePicFromCell(cell)
img = self.getImageFromPic(pic, dimensions, transparent)
if single_file:
imgs.append((irow, icol, cell, pic))
else:
# Generate filename: <basename>-<N>.png:
filename = "%s-%03d.png" % (basename, int(st_num))
img.save(filename)
progress.setValue(st_num)
if single_file:
cell_height, cell_width = EXPORT_IMAGE_HEIGHT, EXPORT_IMAGE_WIDTH
final_img = QtGui.QImage(max_col * cell_width,
max_row * cell_height,
QtGui.QImage.Format_ARGB32)
if transparent:
color = Qt.transparent
else:
color = QtGui.QColor('white')
final_img.fill(color)
painter = QtGui.QPainter()
painter.begin(final_img)
for irow, icol, cell, pic in imgs:
rect = QtCore.QRect(icol * cell_width, irow * cell_height,
cell_width, cell_height)
self.paintOneCell(painter, cell, pic, rect, True)
# Draw an outline around each cell
painter.drawRect(rect)
painter.end()
final_img.save(f'{basename}.png')
# Ensure that progress dialog is closed:
progress.close()
if num_skipped == st_count:
self.warning(
"Failed to export, because every selected structure had too many atoms"
)
return
elif num_skipped > 0 and maestro:
self.warning(
"%i structure(s) were not exported because they had too many atoms"
% num_skipped)
if not canceled:
if single_file:
msg = f"Images were saved to: {basename}.png"
else:
msg = "Images were saved to: %s-NNN.png" % basename
self.info(msg)
return basename
[docs] def exportTo2dSdFile(self, outfile):
"""
Export all structures to a 2D SD file.
"""
writer = Chem.SDWriter(outfile)
st_count = self.getNumCells()
progress = QtWidgets.QProgressDialog("Exporting structures...",
"Cancel", 1, st_count, self)
progress.setWindowModality(Qt.WindowModal)
progress.show()
st_num = 0
num_skipped = 0
canceled = False
for cell in self.getAllCells():
st_num += 1
if progress.wasCanceled():
canceled = True
break # Exit loop
# Ev:84856 if this cell has more than max_atoms # of atoms,
# then st will be None.
# If failed to render or too many atoms:
st = self.getStructToRender(cell)
if st.atom_total > self.max_atoms:
# Structure has more than maximum number of atoms
num_skipped += 1
else:
rdmol = self.generate2dCoords(cell, st, include_hs=False)
writer.write(rdmol)
progress.setValue(st_num)
# Will auto-close progress when reached end of file
writer.close()
# Ensure that progress dialog is closed:
progress.close()
if canceled:
fileutils.force_remove(outfile)
return
if num_skipped == st_count:
self.warning(
"Failed to export, because every selected structure had too many atoms"
)
return
elif num_skipped > 0 and maestro:
self.warning(
"%i structure(s) were not exported because they had too many atoms"
% num_skipped)
self.info("Output file: %s" % outfile)
[docs] def exportTo3dSdWithSmilesFile(self, outfile):
"""
Export all structures to the given 3D SD file, with SMILES string
set as a property.
"""
import schrodinger.structutils.smiles as smiles
smiles_generator = smiles.SmilesGenerator(
stereo=smiles.STEREO_FROM_ANNOTATION_AND_GEOM, unique=True)
writer = structure.SDWriter(outfile)
st_count = self.getNumCells()
progress = QtWidgets.QProgressDialog("Exporting structures...",
"Cancel", 1, st_count, self)
progress.setWindowModality(Qt.WindowModal)
progress.show()
st_num = 0
num_skipped = 0
canceled = False
for cell in self.getAllCells():
st = self.getStructToRender(cell)
st_num += 1
if progress.wasCanceled():
canceled = True
break # Exit loop
if st.atom_total > self.max_atoms:
num_skipped += 1
else:
pattern = smiles_generator.getSmiles(st)
st.property['s_sd_SMILES'] = pattern
writer.append(st)
progress.setValue(st_num)
# Will auto-close progress when reached end of file
writer.close()
# Ensure that progress dialog is closed:
progress.close()
if canceled:
fileutils.force_remove(outfile)
return
if num_skipped == st_count:
self.warning(
"Failed to export, because every selected structure had too many atoms"
)
return
elif num_skipped > 0 and maestro:
self.warning(
"%i structure(s) were not exported because they had too many atoms"
% num_skipped)
self.info("Output file: %s" % outfile)
[docs] def numSelectedCells(self):
"""
Returns number of selected cells.
This method is optimized for speed.
"""
num_selected = 0
item_selection = self.table_view.selectionModel().selection()
for selrange in item_selection:
column_span = selrange.right() - selrange.left() + 1
row_span = selrange.bottom() - selrange.top() + 1
num_selected += column_span * row_span
return num_selected
[docs] def getKplsData(self, st):
"""
Calculate list of color for KPLS annotations, atomic factors, and predicted value.
Returns empty lists if 2D Viewer was not opened via AutoQSAR panel.
:param st: Structure to process
:type st: structure.Structure
:return: List of atom colors, List of factors, predicted activity
:rtype: (list, list, float or None)
"""
if not self.kpls_vis:
return [], [], None
self.kpls_vis.predict(st)
atom_colors = [
kpls_color_from_value(value)
for value in self.kpls_vis.getPredAtomicFactors()
]
atom_factors = self.kpls_vis.getPredAtomicFactors()
# predict() call will add any missing hydrogens when calculating
# KPLS factors, so strip those extra values, if they are present:
natoms = st.atom_total
if len(atom_factors) > natoms:
atom_factors = atom_factors[:natoms]
assert len(atom_factors) == natoms
pred_value = self.kpls_vis.getPredY()
return atom_colors, atom_factors, pred_value
[docs] def getPropertyLabels(self, st):
"""
Calculate list of strings for property annotations. Returns empty list if there's nothing to display
"""
if not self.show_atom_anns_action.isChecked(
) or not self.annotate_property:
return []
property_strings = [
property_string(a, self.annotate_property) for a in st.atom
]
return property_strings
[docs] def loadRendererForCell(self, cell):
"""
Return sketcher.Renderer class, populated with structure from the
given cell. Returns None if structure has too many atoms.
"""
# TODO break this method down into multiple methods.
st = self.getStructToRender(cell)
if st.atom_total > self.max_atoms:
return None
maestrohub = maestro_ui.MaestroHub.instance()
settings = maestrohub.get2DRenderSettings()
rdmol = self.generate2dCoords(cell,
st,
include_hs=True,
coordinates_for_hs=settings.drawAllHs)
rend = sketcher.Renderer()
settings.skipCleanUp = True
rend.loadSettings(settings)
# Rotate and flip coordinates, if needed:
rend.flipAndRotate(cell.flip_horizontal, cell.flip_vertical,
-cell.rotate_degrees)
kpls_colors, atom_factors, pred_value = self.getKplsData(st)
cell.atom_factors = atom_factors
if kpls_colors:
rend.addAtomHalos(kpls_colors)
st.property[QSAR_PREDICTION_PROP] = pred_value
property_labels = self.getPropertyLabels(st)
if property_labels:
rend.labelAtoms(property_labels)
rend.loadStructure(rdmol)
if cell.is_reference:
# For the reference, always color all bonds cyan:
color_atoms = rend.getScene().quickGetAtoms()
rend.colorAtoms(list(range(len(color_atoms))), REF_HIGHLIGHT_COLOR)
elif self.highlight_mcs_action.isChecked() and cell.alignment_info:
# For non-reference cells, if highlighting is enabled, color
# the MCS with reference as magenta
_, _, core_atoms = cell.alignment_info
# Determine bonds to highlight based on the atom list:
rend.colorAtoms(core_atoms, MCS_HIGHLIGHT_COLOR)
return rend
[docs] def generatePicFromCell(self, cell):
"""
Generate QPicture of the 2D structure of the cell. If structure
has too many atoms, "Too many atoms to display: X" will be drawn.
"""
try:
rend = self.loadRendererForCell(cell)
except:
text = 'Failed to render.'
return generate_pic_with_text(text)
if rend:
return rend.getPicture()
# rend will be None if there are too many atoms in structure
text = f'Too many atoms\n to display: {cell.st.atom_total}'
return generate_pic_with_text(text)
[docs] def getPictureForCell(self, cell):
"""
Return a QPicture object for the given cell. Cached picture is
used if present - if not, new QPicture is generated and cached
in the delegate.
"""
return self.table_delegate.generatePictureForCell(cell, cell.entry_id)
[docs] def generate2dCoords(self, cell, st, include_hs, coordinates_for_hs=False):
"""
Generate 2D coordinates for the given cell. If it's a reference, or no
alignment to reference is done, coordinates are based on the cell
entry's 3D coordinates. If there is a reference present, and cell is
aligned to it, the reference 3D structure is used as template.
:param include_hs: Whether to include explicit hydrogens, as required
by Sketcher in order to properly render tautomeric states. "Note
that if coordinates_for_hs is False, hydrogen coordinates will NOT
be correct, as they will be taken from original structure,
pre-conformer generation"
:type include_hs: bool
:param coordinates_for_hs: Whether to generate coordinates for explicit
hydrogens, in case the structure needs to be displayed with
explicit hs
:type coordinates_for_hs: bool
"""
# TODO: Consider saving original Structure atom numbers in
# Cell.alignment_info.
mol_to_display = rdkit_adapter.to_rdkit(
st, sanitize=False, implicitH=not coordinates_for_hs)
# sanitize everything except properties:
rdmolops.SanitizeMol(
mol_to_display,
rdmolops.SANITIZE_ALL ^ rdmolops.SANITIZE_PROPERTIES)
# Generate a 2D conformer based on any MCS alignment information,
# if present:
cg_params = rdCoordGen.CoordGenParams()
#CRDGEN-264 treat all non terminal bonds to metals as zero order bonds.
#This is the standard for coordgen, but is off by default in the rdkit implementation
#until https://github.com/rdkit/rdkit/issues/4055 is done
cg_params.treatNonterminalBondsToMetalAsZOBs = True
if cell.alignment_info:
# If aligning to reference, using reference structure as template
ref_conf, ref_core_atoms, core_atoms = cell.alignment_info
cmap = {}
for i, refi in zip(core_atoms, ref_core_atoms):
coords = Geometry.Point2D(ref_conf.GetAtomPosition(refi))
cmap[i] = coords
cg_params.SetCoordMap(cmap)
rdCoordGen.AddCoords(mol_to_display, cg_params)
if include_hs and not coordinates_for_hs:
# If explicit hydrogens are needed (as required for Sketcher):
# Create another RDMol, with explicit hydrogens:
mol_Hs = rdkit_adapter.to_rdkit(st, sanitize=False)
# Copy over coordinates of heavy atoms from the 2D conformer to the
# new mol that contains the hydrogens:
copy_coords_of_heavy_atoms(mol_to_display, mol_Hs)
return mol_Hs
else:
# Return structure with implicit hydrogens. Used by 2D SDF export.
return mol_to_display
def _retrieveLigand(self, st):
"""
Ev:94590 If given structure is a receptor, retrieve the ligand from it
and return the ligand st.
2nd return value is bool - whether to show the "protein" icon in the
cell or not.
"""
if st.atom_total < self.max_atoms:
return st, False
has_protein = True
matched_atoms = analyze.evaluate_asl(st, "ligand")
# TODO consider using AslLigandSearcher instead, which will add support
# for handling multiple covalently bound ligands.
if len(matched_atoms) == 0:
# No ligands found
return st, has_protein
matched_st = st.extract(matched_atoms, copy_props=True)
if len(matched_st.molecule) > 1:
# Multiple ligands found, take the first one:
return_st = matched_st.molecule[1].extractStructure(copy_props=True)
else:
# One ligand found
return_st = matched_st
return return_st, has_protein
[docs] def getImageFromPic(self, pic, dimensions=None, transparent=False):
"""
Generates a QImage from the given QPicture.
:type pic: `QtGui.QPicture`
:param pic: QPicture to render
:type dimensions: (int, int)
:param dimensions: Width and height of the image to produce.
if None, QPicture's bounding rect will be used for sizing, and
image size will depend on size of the structure - larger structures
will take up more pixels. Also aspect ratio will NOT be the same
for all images.
:type transparent: bool
:param transparent: Whether to use transparent background instead
of white.
"""
if dimensions:
width, height = dimensions
else:
scale = EXPORT_NOSCALE_SCALE
picrect = pic.boundingRect()
width = picrect.width() * scale
height = picrect.height() * scale
img = QtGui.QImage(QtCore.QSize(width, height),
QtGui.QImage.Format_ARGB32)
img.fill(0) # Initialize with zeros to overwrite garbage values
painter = QtGui.QPainter()
painter.begin(img)
if not transparent:
# Fill with white:
color = QtGui.QColor('white')
painter.fillRect(0, 0, width, height, color)
else:
# Will with transparent background:
painter.fillRect(0, 0, width, height, Qt.transparent)
if dimensions:
# Generate images of equal height/width
destrect = QtCore.QRect(0, 0, width, height)
swidgets.draw_picture_into_rect(painter, pic, destrect,
self.max_scale_factor,
PADDING_FACTOR)
else:
# Generate images such that all bonds are same length, used only
# in PNG export. TODO consider padding the QPicture here as well.
painter.scale(scale, scale)
painter.drawPicture(-pic.boundingRect().left(),
-pic.boundingRect().top(), pic)
painter.scale(1.0 / scale, 1.0 / scale)
painter.end()
return img
[docs] def generateHtml(self, outfile):
"""
Generates the HTML file from the saved images
"""
cells = self.getAllCells()
num_cols = self.table_model.columnCount()
# Generate the image directory:
image_dir = "%s_img" % fileutils.splitext(outfile)[0]
if os.path.isfile(image_dir):
try:
os.remove(image_dir)
except:
raise RuntimeError("Failed to remove existing file: %s" %
image_dir)
elif os.path.isdir(image_dir):
try:
shutil.rmtree(image_dir)
except:
raise RuntimeError("Failed to remove existing directory: %s" %
image_dir)
try:
os.mkdir(image_dir)
except:
raise RuntimeError("Failed to create directory: %s" % image_dir)
base_name = os.path.join(image_dir, fileutils.get_basename(outfile))
num_sts = len(cells)
progress = QtWidgets.QProgressDialog("Exporting structures...",
"Cancel", 0, num_sts * 2, self)
progress.setWindowModality(Qt.WindowModal)
progress.show()
# Ensure all cells have same with, even if not scaling images:
CELL_WIDTH = EXPORT_IMAGE_WIDTH
# TODO vary the cell width based on widest image when not scaling?
table_width = CELL_WIDTH * num_cols
prop_width = CELL_WIDTH / 2 - 4
PROP_CSS = '''
width:%i;
display:inline-block;
overflow:hidden;
white-space:nowrap;
padding: 0 2 0 2;
background-color:%s;
''' % (prop_width, PROP_BACKGROUND_COLOR_HEX)
PROP_CSS = ' '.join(PROP_CSS.split()) # put on one line
PROP_CSS_WITH_LINE = PROP_CSS + ' border-bottom: 1px solid black;'
CSS = '''
table{
font-family:arial;
font-size:80%%;
table-layout:fixed;
width:%i;
td{
padding: 3px;
}
''' % table_width
html_txt = '<html><head><style>%s</style></head><body>' % CSS
html_txt += '<table border="1">'
col = 0
canceled = False
for i, cell in enumerate(cells):
# TODO consider setting dimensions to fixed width/height
pic = self.generatePicFromCell(cell)
# Write images at double resolution, to enable better images
# during zoom-in, and for better viewing on retina displays:
dimensions = (EXPORT_IMAGE_WIDTH * 2.0, EXPORT_IMAGE_HEIGHT * 2.0)
img = self.getImageFromPic(pic, dimensions)
# Generate filename: <basename>-<N>.png:
image_filename = "%s-%03d.png" % (base_name, int(i + 1))
img.save(image_filename)
# Ev:83257 Put relative image path into the HTML file:
tmp_dirpath, filename = os.path.split(image_filename)
dirname = os.path.split(tmp_dirpath)[1]
# HTML uses forward slash as path separator:
relative_image_fname = html.escape("%s/%s" % (dirname, filename))
progress.setValue(progress.value() + 1)
if progress.wasCanceled():
canceled = True
break
col += 1
if col == 1:
# Start a new row
html_txt += '\n <tr>'
# Treat quotes right:
st_title = html.escape(cell.st.title)
cell_txt = '<img alt="%s" src="%s" width="%i" height="%i"/>' % (
st_title, relative_image_fname, EXPORT_IMAGE_WIDTH,
EXPORT_IMAGE_HEIGHT)
props = self.getPropsToShowForCell(cell)
# Write labels to the same cell:
num_props = len(props)
for iprop, (prop_uname, value) in enumerate(props, start=1):
value_str = html.escape(str(value))
cell_txt += '<br>'
if self.display_property_names:
if self.use_title_caps:
prop_uname = prop_uname.upper()
prop_uname = html.escape(prop_uname)
if iprop == num_props:
# Last property; omit the bottom line
css = PROP_CSS
else:
css = PROP_CSS_WITH_LINE
label = '<div style="%s">%s</div>' % (css, prop_uname)
label += '<div style="%s">%s</div>' % (css, value_str)
cell_txt += label
else:
cell_txt += value_str
html_txt += '\n <td width="%i">%s</td>' % (CELL_WIDTH,
cell_txt)
if col == num_cols:
col = 0
html_txt += '\n </tr>'
if col != 0:
# If the last row was not completed:
while col != num_cols:
# Will this row with cells until the end:
html_txt += '\n <td> </td>'
col += 1
html_txt += '\n </tr>'
html_txt += '\n</table></body></html>'
with open(outfile, 'w') as fh:
fh.write(html_txt)
# Ensure that progress dialog is closed:
progress.close()
if not canceled:
self.info(
"The 2D report was successfully exported.\n\nReport file:\n%s" %
outfile)
[docs] def generatePdf(self, outfile):
"""
Generates the PDF file from the saved images.
:type outfile: str
:param outfile: Path to the PDF file to generate.
"""
cells = self.getAllCells()
num_cols = self.table_model.columnCount()
fileutils.force_remove(outfile)
num_sts = len(cells)
progress = QtWidgets.QProgressDialog("Exporting structures...",
"Cancel", 0, num_sts * 2, self)
progress.setWindowModality(Qt.WindowModal)
progress.show()
printer = QtPrintSupport.QPrinter()
printer.setFullPage(True)
printer.setPageSize(QtPrintSupport.QPrinter.Letter)
printer.setOrientation(QtPrintSupport.QPrinter.Portrait)
printer.setOutputFileName(outfile)
if sys.platform == 'win32':
printer.setOutputFormat(
QtPrintSupport.QPrinter.PdfFormat) # Fix for Ev:131693
elif sys.platform == 'darwin':
printer.setOutputFormat(
QtPrintSupport.QPrinter.NativeFormat) # Fix for Ev:124251
else:
pass # Linux
painter = QtGui.QPainter()
painter.begin(printer) # may fail to open the file
# FIXME raise a clean exception if the file can't be opened
column_width = (printer.width() - (PDF_PAGE_MARGIN * 2)) / num_cols
hw_ratio = EXPORT_IMAGE_HEIGHT / EXPORT_IMAGE_WIDTH
pic_height = int(column_width * hw_ratio)
props_height = (self.font_height * len(self.selected_props))
row_height = pic_height + props_height
# Separate the pictures by rows:
rows = []
for i, cell in enumerate(cells):
if i % num_cols == 0:
rows.append([])
rows[-1].append(cell)
# Draw the pictures and labels:
canceled = False
for irow, cells in enumerate(rows):
if irow == 0:
# First row
rowtop = PDF_PAGE_MARGIN
else:
# Not the first row; determine if we should start a new page:
if rowtop + row_height > printer.height():
printer.newPage()
rowtop = PDF_PAGE_MARGIN
# Now draw every cell of this row:
for icol, cell in enumerate(cells):
progress.setValue(progress.value() + 1)
if progress.wasCanceled():
canceled = True
break
x = PDF_PAGE_MARGIN + (icol * column_width)
rect = QtCore.QRect(x, rowtop, column_width, row_height)
pic = self.generatePicFromCell(cell)
self.paintOneCell(painter, cell, pic, rect, True)
# Draw an outline around each cell
outline = QtCore.QRect(x, rowtop, column_width, row_height)
painter.drawRect(outline)
# TODO in the spec it is mentioned that grid lines should
# be added between cells.
# The bottom of this row will become the top of the next row:
rowtop += row_height
painter.end()
# Ensure that progress dialog is closed:
progress.close()
if not canceled:
self.info(
"The 2D report was successfully exported.\n\nReport file:\n%s" %
outfile)
[docs] def clearAutQsarModel(self):
"""
Clear AutQSAR model.
"""
self.kpls_vis = None
self.selected_props = ['s_m_title']
self.refreshTable()
[docs] def setAutoQsarModel(self, kpls_file, model_id, fit_prop):
"""
Set the KPLS model to be used for visualization. If set to (None, None)
clear the KPLS visualization model.
:param kpls_file: filename for kpls.tar.gz file from model
:type kpls_file: str
:param model_id: current model name or factor index
:type model_id: str or int
:param fit_prop: The name of the predicted (y) property.
:type fit_prop: str
"""
# By default, in addition to the title, show predicted property and
# observed / property fitted by (if present):
# NOTE: prediction property will be dynamically added to each structure
# as the prediction is made.
self.selected_props = ['s_m_title', fit_prop, QSAR_PREDICTION_PROP]
self.precisions[QSAR_PREDICTION_PROP] = 3
self.kpls_vis = canvassharedguiSWIG.ChmKPLSVis(kpls_file, model_id)
self.refreshTable()