Source code for schrodinger.ui.two_d_viewer

"""
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 transform_point(point, src_rect, dest_rect): """ Transform the `point` from coordinates represented by src_rect to coordinates represneted by dest_rect. :param point: Point to transform :type point: QtCore.QPoint :param src_rect: Rect defining coordinate system of input point. :type src_rect: QtCore.QRect :param dest_rect: Rect defining destination coordinate system. :type dest_rect: QtCore.QRect :return: Transform coordinate point. :rtype: QtCore.QPoint """ localx = point.x() localy = point.y() xscale = dest_rect.width() / src_rect.width() yscale = dest_rect.height() / src_rect.height() out_x = (localx - src_rect.left()) * xscale + dest_rect.left() out_y = (localy - src_rect.top()) * yscale + dest_rect.top() return QtCore.QPoint(out_x, out_y)
[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 ZoomWidget(QtWidgets.QWidget):
[docs] def __init__(self, parent=None): self.viewer = parent QtWidgets.QWidget.__init__(self, parent)
[docs] def paintEvent(self, ignored_event): # TODO instead store an image in this class, and have panel update # the image when the structure changes; this way reference to panel # is not needed here. cell = self.viewer.getZoomCell() pic = self.viewer.generatePicFromCell(cell) painter = QtGui.QPainter() # Draw into the whole widget: rect = self.rect() bgbrush = QtGui.QBrush(QtGui.QColor("white")) painter.begin(self) painter.fillRect(rect, bgbrush) self.viewer.paintOneCell(painter, cell, pic, rect, False) painter.end()
[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 clearTransformations(self): self.rotate_degrees = 0 self.flip_horizontal = False self.flip_vertical = False
[docs] def copyTransformations(self, cell): """ Copies the transformations from the given cell to this cell """ self.rotate_degrees = cell.rotate_degrees self.flip_horizontal = cell.flip_horizontal self.flip_vertical = cell.flip_vertical
[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 CellContextualMenu(QtWidgets.QMenu):
[docs] def __init__(self, viewer, cell, row): super().__init__(viewer) self.viewer = viewer self.cell = cell self.row = row
[docs] def show(self, pos): """ Show contextual menu for the given 2D cell, at the current position of the mouse cursor. """ row = self.row viewer = self.viewer if viewer.enable_maestro_features: if row.in_workspace: self.addAction("Exclude from 3D Workspace", self.excludeEntries) else: self.addAction("Include in 3D Workspace", self.includeEntries) self.addAction("Exclude Other Entries", self.excludeOtherEntries) self.addSeparator() if row.is_selected: self.addAction("Deselect in Maestro Project", self.deselectEntries) else: self.addAction("Select in Maestro Project", self.selectEntries) self.addAction("Deselect Other Entries", self.deselectOtherEntries) self.addSeparator() # These 2 items apply to the right-clicked cell only: self.addAction("Copy This Image", self.copyToClipboard) self.addAction("Set Structure as Reference", self.setAsReference) self.addSeparator() # These 2 items apply to all selected items: self.addAction("Reset to Default Orientation", self.removeAlignments) if self.viewer.panel and not self.viewer.panel.ui.auto_update_cb.isChecked( ): self.addAction("Remove from 2D Viewer", self.viewer.removeSelectedFromTable) self.exec(pos)
[docs] def updateView(self): # Re-draw the cell with the new icon: self.viewer.table_view.viewport().update()
[docs] def includeEntries(self): """ Include selected cells in the Workspace. """ pt = maestro.project_table_get() include_eids = self.viewer.getSelectedEntryIds() pt.includeRows(include_eids, exclude_others=False) self.updateView()
[docs] def excludeEntries(self): """ Exclude all selected cells from the Workspace. """ pt = maestro.project_table_get() included_eids = maestro.get_included_entry_ids() for eid in self.viewer.getSelectedEntryIds(): if eid in included_eids: included_eids.remove(eid) pt.includeRows(included_eids, exclude_others=True) self.updateView()
[docs] def excludeOtherEntries(self): """ Exclude all entries from the Workspace, except those for selected 2D viewer cells. """ pt = maestro.project_table_get() included_eids = maestro.get_included_entry_ids() selected_eids = self.viewer.getSelectedEntryIds() for cell in self.viewer.getAllCells(): eid = cell.entry_id if eid in included_eids and eid not in selected_eids: included_eids.remove(eid) pt.includeRows(included_eids, exclude_others=True) self.updateView()
[docs] def selectEntries(self): """ Select in PT those entries that are selected in 2D viewer. """ pt = maestro.project_table_get() selected_in_2d_viewer = self.viewer.getSelectedEntryIds() pt.selectRows(project.ADD, entry_ids=selected_in_2d_viewer) self.updateView()
[docs] def deselectOtherEntries(self): """ Deselect everything from PT except cells that are selected in 2D Viewer """ pt = maestro.project_table_get() selected_in_2d_viewer = self.viewer.getSelectedEntryIds() select_eids = [ row.entry_id for row in pt.selected_rows if row.entry_id in selected_in_2d_viewer ] pt.selectRows(project.REPLACE, entry_ids=select_eids) self.updateView()
[docs] def deselectEntries(self): """ Deselect in PT those cells that are selected in 2D Viewer. """ pt = maestro.project_table_get() selected_eids = [row.entry_id for row in pt.selected_rows] for eid in self.viewer.getSelectedEntryIds(): if eid in selected_eids: selected_eids.remove(eid) pt.selectRows(project.REPLACE, entry_ids=selected_eids) self.updateView()
[docs] def copyToClipboard(self): self.viewer.copyToClipboard(self.cell)
[docs] def setAsReference(self): self.viewer.setCellAsReference(self.cell)
[docs] def removeAlignments(self): """ Remove alignments to reference for selected cells. """ for cell in self.viewer.iterateOverSelectedCells(): cell.clearAlignment() self.updateView()
[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] def hideStructureTooltip(self): # Hack to work with the table.py module pass
[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 populateSaveExportMenu(self): self.save_as_menu = QtWidgets.QMenu() self.save_as_menu.setToolTipsVisible(True) self.images_action = QtGui.QAction('Images...') self.images_action.setToolTip( 'Save individual structures as PNG files, grid as PDF or HTML') self.images_action.triggered.connect(self.exportImages) self.structure_action = QtGui.QAction('Structures...') self.structure_action.setToolTip( 'Export structures as 2D SD, or 3D SD with SMILES') self.structure_action.triggered.connect(self.exportStructures) self.png_transparent_bg_action = QtGui.QAction( 'Use Transparent Background (PNG)') self.png_transparent_bg_action.setToolTip( 'Save PNG images with transparent (versus white) background') self.png_transparent_bg_action.setCheckable(True) self.png_scale_action = QtGui.QAction('Scale Images (PNG)') self.png_scale_action.setToolTip( 'Keep same dimensions on PNG images (versus fixed bond lengths)') self.png_scale_action.setCheckable(True) self.png_single_grid = QtGui.QAction('Create Single Grid Image (PNG)') self.png_single_grid.setToolTip( 'Creates a single grid image for exporting all PNG structures') self.png_single_grid.setCheckable(True) self.save_as_menu.addAction(self.images_action) self.save_as_menu.addAction(self.structure_action) self.save_as_menu.addSeparator() self.save_as_menu.addAction(self.png_transparent_bg_action) self.save_as_menu.addAction(self.png_scale_action) self.save_as_menu.addAction(self.png_single_grid) self.ui.save_as_btn.setStyleSheet(""" QToolButton:pressed { padding: 1px -1px -1px 1px; } QToolButton::menu-indicator { image: none; } """) self.ui.save_as_btn.setMenu(self.save_as_menu)
[docs] def populateViewMenu(self): menu = QtWidgets.QMenu(self) self.ui.view_action_btn.setMenu(menu) self.grid_layout_action = QtGui.QAction('Grid Layout') self.grid_layout_action.triggered.connect(self.exitZoomView) self.grid_layout_action.setCheckable(True) self.grid_layout_action.setChecked(True) menu.addAction(self.grid_layout_action) self.single_view_action = QtGui.QAction('Single Structure') self.single_view_action.triggered.connect(self.enterZoomView) self.single_view_action.setCheckable(True) self.single_view_action.setChecked(True) menu.addAction(self.single_view_action) self.column_submenu = menu.addMenu('Columns') self.column_group = QtGui.QActionGroup(self) self.column_group.setExclusive(True) self.column_group.triggered.connect(self.columnActionSelected) self.column_actions = [] for i in range(1, 11): item_action = QtGui.QAction(str(i)) item_action.setCheckable(True) item_action.setData(i) self.column_submenu.addAction(item_action) self.column_group.addAction(item_action) self.column_actions.append(item_action) menu.addMenu(self.column_submenu) menu.addSeparator() if self.enable_maestro_features: self.link_sel_and_inc_action = QtGui.QAction( 'Link 2D Selection and Inclusion') self.link_sel_and_inc_action.triggered.connect( self.linkSelectionAndInclusionToggled) self.link_sel_and_inc_action.setCheckable(True) self.link_sel_and_inc_action.setChecked(True) menu.addAction(self.link_sel_and_inc_action) self.show_entry_props_action = QtGui.QAction('Display Entry Properties') self.show_entry_props_action.triggered.connect(self.propsChanged) self.show_entry_props_action.setCheckable(True) self.show_entry_props_action.setChecked(True) menu.addAction(self.show_entry_props_action) menu.addAction('Manage Entry Properties...', self.displayPropsDialog) menu.addSeparator() self.highlight_mcs_action = QtGui.QAction( 'Highlight Aligned Substructures') self.highlight_mcs_action.triggered.connect(self.refreshTable) self.highlight_mcs_action.setCheckable(True) self.highlight_mcs_action.setChecked(False) menu.addAction(self.highlight_mcs_action) self.show_atom_anns_action = QtGui.QAction('Display Atom Annotation') self.show_atom_anns_action.triggered.connect(self.propsChanged) self.show_atom_anns_action.setCheckable(True) self.show_atom_anns_action.setChecked(False) menu.addAction(self.show_atom_anns_action) menu.addAction('Choose Atom Property...', self.atomPropDialog) menu.addSeparator() self.neutralize_action = QtGui.QAction('Show Neutral Form') self.neutralize_action.triggered.connect(self.neutralizeToggled) self.neutralize_action.setCheckable(True) self.neutralize_action.setChecked(False) menu.addAction(self.neutralize_action) menu.addAction('2D Structure Preferences...', self.openDisplayPreferences)
[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 getTooltipForPosition(self, pos, cell): """ Return a tool tip to show at the given position. Currently only works with single structure (zoom) view, and only for models with KPLS data. :return: Tooltip text :rtype: str or None """ # TODO consider saving the renderer instance, so that we # don't re-create it every time user moves the mouse. rend = self.loadRendererForCell(cell) if rend is None: # Structure failed to render return None pic_pos = transform_point(pos, cell.pic_dest_rect, cell.pic_bounds_rect) atom = rend.getScene().atomAt(pic_pos) if atom is None: # Mouse is not over an atom return None index = atom.getIndexInMolecule() # 0-indexed atom num kpls_factor = cell.atom_factors[index] return "%.3f (atom %i)" % (kpls_factor, index + 1)
[docs] def updatePreviousAndNextButtons(self): """ Enable or disable "<" and ">" buttons as needed. """ cell = None if self.inZoomMode(): cell = self.getZoomCell() else: num = self.numSelectedCells() if num == 1: cell = self.getSelectedCell() if cell is not None: # If exactly one cell is selected i = self.table_model.getIFromCell(cell) 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) else: self.ui.previous_button.setEnabled(False) self.ui.next_button.setEnabled(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 zoomContextMenuRequested(self, pos): assert self.zoom_i is not None cell = self.getZoomCell() self.showContextMenu(cell)
[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 gridContextMenuRequested(self, pos): """ Handle the display of contextual menu when the user right-clicks on a row in the table. """ index = self.sender().indexAt(pos) if not index: # Clicked outside of a row return cell = index.data(CELL_ROLE) if cell is None: # Place-holder cell return self.showContextMenu(cell)
[docs] def showContextMenu(self, cell): """ Show contextual menu for the given 2D cell, at the current position of the mouse cursor. """ if self.enable_maestro_features: row = cell.getProjectRow() if row is None: self.warning('Entry is no longer in the Project Table.') return else: row = None self.context_menu = CellContextualMenu(self, cell, row) self.context_menu.show(QtGui.QCursor.pos())
[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 updateReferenceMenu(self): self.table_model.removeReference() with qtutils.suppress_signals(self.ui.reference_combo): self.ui.reference_combo.clear() self.ui.reference_combo.addItem('None') titles = self.table_model.getTitles() self.ui.reference_combo.addItems(titles)
[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()