"""
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.utils import fileutils
from schrodinger.utils import log
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import utils as qt_utils
try:
    from schrodinger.application.canvas.packages import canvassharedguiSWIG
except ImportError:
    canvassharedguiSWIG = None
maestro = schrodinger.get_maestro()
# Check whether SCHRODINGER_PYTHON_DEBUG is set for debugging:
DEBUG = (log.get_environ_log_level() <= log.DEBUG)
QSAR_PREDICTION_PROP = 'r_user_Predicted_Value'
# Image size export (HTML, PDF, and PNG):
EXPORT_IMAGE_HEIGHT = 300
EXPORT_IMAGE_WIDTH = 400
# How much to pad the outside/border of 2D structure images:
PADDING_FACTOR = 0.04
# When exporting images "unscaled" (using the size that rdkit uses for
# rendering the QPictures), sale the images down a little, so that each bond
# length is 60 pixels (0.2 inches at 300dpi) instead of 100 pixels.
EXPORT_NOSCALE_SCALE = 0.6
PDF_PAGE_MARGIN = 10
PNG_SINGLE_PAGE_EXPORT_THRESHOLD = 100
# Entry ID for this cell (or Structure handle if running outside of Maestro)
ENTRY_ID_ROLE = Qt.UserRole + 100
# Cell object:
CELL_ROLE = Qt.UserRole + 101
# model index (int)
MODEL_INDEX_ROLE = Qt.UserRole + 103
PROP_BACKGROUND_COLOR = QtGui.QColor(220, 220, 220)
PROP_BACKGROUND_COLOR_HEX = PROP_BACKGROUND_COLOR.name()  # "#RRGGBB" string
PROP_LINE_COLOR = QtGui.QColor('#bababa')
TEXT_COLOR = QtGui.QColor(0, 0, 0)
MCS_HIGHLIGHT_COLOR = QtGui.QColor('#ff2eff')
REF_HIGHLIGHT_COLOR = QtGui.QColor('#2e96ff')
# 2 possible texts for the show/hide actions button:
MORE_ACTIONS_TEXT = 'More actions'
HIDE_ACTIONS_TEXT = 'Hide actions'
GUI_DIR = os.path.abspath(
    os.path.join(os.path.dirname(__file__), 'two_d_viewer_icons'))
[docs]def get_icon(path):
    return QtGui.QIcon(os.path.join(GUI_DIR, path)) 
[docs]def st_to_rdmol_without_hydrogens(st):
    """
    Create a sanitized RDMol from Structure. Used for MCS calculation only.
    Hydrogens are exlucded to make the calculation faster, and to prevent
    hangs.
    NOTE: Atom indices will be different from the original structure, because
    explicit hydrogens will be stripped (and if any hydrogen has lower atom
    index than a heavy atom, heavy atoms will get renumbered).
    :param st: Structure to convert.
    :type st: structure.Structure
    :return: RDKit molecule.
    :rtype: Chem.Mol
    """
    # implicitH speeds this up significantly, especially for complicated
    # molecules, on which MCS calculation can hang. We do custom
    # sanitation here in order to handle molecules with bad valences,
    # yet still be able to properly handle aromatic bonds.
    mol = rdkit_adapter.to_rdkit(st, sanitize=False, implicitH=True)
    # sanitize everything except properties:
    rdmolops.SanitizeMol(mol,
                         rdmolops.SANITIZE_ALL ^ rdmolops.SANITIZE_PROPERTIES)
    return mol 
[docs]def copy_coords_of_heavy_atoms(mol_noHs, mol_Hs):
    """
    Copy coordinates of heavy atoms from the first structure to the second.
    Sketcher prefers a molecule with explicit Hs in order to accurately reproduce
    the tautomeric state of a molecule
    :param mol_noHs: RDKit template (with implicit Hs) to copy heavy atom coordinates from
    :type mol_noHs: Chem.Mol
    :param mol_Hs: RDKit molecule to update.
    :type mol_Hs: Chem.Mol
    """
    conf_Hs = mol_Hs.GetConformer()
    conf_noHs = mol_noHs.GetConformer()
    sdgr_to_rdk = {
        a.GetProp(adapter.SCHRODINGER_INDEX): a.GetIdx()
        for a in mol_Hs.GetAtoms()
    }
    for atom_noHs in mol_noHs.GetAtoms():
        st_idx_noHs = atom_noHs.GetProp(adapter.SCHRODINGER_INDEX)
        position = conf_noHs.GetAtomPosition(atom_noHs.GetIdx())
        conf_Hs.SetAtomPosition(sdgr_to_rdk[st_idx_noHs], position) 
[docs]def get_mcs_atoms(rdmol1, rdmol2):
    """
    Find the maximum common structure between the given structures,
    and return atom lists for each molecule that match the MCS. Lists
    are 0-indexed, because they are then passed on to rdkit. Input structures
    should not have explicit Hs, to make the calculation faster and
    avoid hangs on certen molecules
    :param rdmol1: First molecule
    :type rdmol1: Chem.Mol
    :param rdmol2: Second molecule
    :type rdmol2: Chem.Mol
    :return: (list of MCS atoms in rdmol1, list of MCS atoms in rdmol2)
    :rtype: (list, list)
    """
    mols = [rdmol1, rdmol2]
    res = rdFMCS.FindMCS(mols,
                         ringMatchesRingOnly=True,
                         completeRingsOnly=False,
                         timeout=1)
    mcs_smarts = res.smartsString
    if mcs_smarts == '':
        if res.canceled:
            # Time out; smartsString will be set to the largest common
            # substructure found so far, if any.
            raise RuntimeError('Timed out')
        else:
            raise RuntimeError('No MCS found')
    # Assemble list of atoms in template and this entry that represent
    # the core
    mcs_mol = Chem.MolFromSmarts(mcs_smarts)
    matches = rdmol1.GetSubstructMatches(mcs_mol)
    tmatches = rdmol2.GetSubstructMatches(mcs_mol)
    core_atoms1 = list(matches[0])
    core_atoms2 = list(tmatches[0])
    # TODO if multiple matches, consider using the match that is closer to
    # the middle of the molecule (as rendered in 2D).
    return core_atoms1, core_atoms2 
[docs]def kpls_color_from_value(value):
    """
        Calculate color corresponding to value for kpls annotations. Blue is minimum and red is maximum
    """
    saturation = 0.1
    # Interpolate color between blue (min) and red (max).
    interp = 0.0
    absv = math.fabs(value)
    if absv < saturation:
        interp = 255 * (saturation - absv) / saturation
    g = interp
    if value >= 0.0:
        r = 255
        b = interp
    else:
        r = interp
        b = 255
    return QtGui.QColor(r, g, b) 
[docs]def property_string(atom, property_name):
    value = atom.property.get(property_name)
    if value is None:
        return ""
    if type(value) == type(1.0):
        return f'{value:.1f}'
    else:
        return str(value) 
[docs]def generate_pic_with_text(text):
    """
    Create a QPicture with the text painted on it.
    :param text: Text to draw in the picture.
    :type text: str
    :return: The QPicture with the text.
    :rtype: QtGui.QPicture.
    """
    pic = QtGui.QPicture()
    pic.setBoundingRect(QtCore.QRect(0, 0, 150, 100))
    painter = QtGui.QPainter(pic)
    painter.setFont(QtGui.QFont("Arial", 15))
    painter.drawText(pic.boundingRect(), Qt.AlignVCenter, text)
    painter.end()
    return pic 
[docs]class CtPropsDialog(basewidgets.BaseDialog):
    """
    Dialog for letting the user select which CT-level properties to show.
    """
    ctPropertiesChanged = QtCore.pyqtSignal(list, bool, bool)
[docs]    def initSetOptions(self):
        super().initSetOptions()
        self.help_topic = "PROJECT_MENU_2D_VIEWER_PROPERTIES_DB"
        self.std_btn_specs = {
            self.StdBtn.Ok: self.accept,
            self.StdBtn.Cancel: self.reject
        } 
[docs]    def __init__(self, viewer, all_props, selected_props, display_names,
                 use_title_caps):
        super().__init__(viewer)
        prop_objects = []
        available_datanames = []
        for prop in all_props:  # all_props is a set
            obj = structure.PropertyName(prop)
            prop_objects.append(obj)
            available_datanames.append(prop)
        limited_selected_props = []
        for prop in selected_props:
            if prop in available_datanames:
                prop = structure.PropertyName(prop)
                limited_selected_props.append(prop)
        self.setWindowTitle("2D Viewer - Select Properties")
        prop_sel_widget = QtWidgets.QWidget()
        self.property_selector = propertyselector.PropertySelector(
            prop_sel_widget,
            multi=True,
            presort=False,
            show_aux_listbox=True,
            move_to_aux=True,
            show_family_menu=True,
            show_alpha_toggle=True,
            show_filter_field=True,
        )
        self.property_selector.setProperties(prop_objects,
                                             limited_selected_props)
        self.main_layout.addWidget(prop_sel_widget)
        self.display_property_names_box = QtWidgets.QCheckBox(prop_sel_widget)
        self.display_property_names_box.setText('Display property names')
        self.display_property_names_box.setChecked(display_names)
        self.use_title_caps_cb = QtWidgets.QCheckBox(prop_sel_widget)
        self.use_title_caps_cb.setChecked(use_title_caps)
        self.use_title_caps_cb.setText('Use title caps')
        checkboxes_layout = QtWidgets.QHBoxLayout()
        checkboxes_layout.addWidget(self.display_property_names_box)
        checkboxes_layout.addWidget(self.use_title_caps_cb)
        checkboxes_layout.addStretch()
        self.main_layout.addLayout(checkboxes_layout) 
[docs]    def accept(self):
        show_names = self.display_property_names_box.isChecked()
        title_caps = self.use_title_caps_cb.isChecked()
        show_props = self.property_selector.getSelected()
        prop_list = [prop.dataName() for prop in show_props]
        self.ctPropertiesChanged.emit(prop_list, show_names, title_caps)
        super().accept()  
[docs]class AtomPropertyDialog(basewidgets.BaseDialog):
    """
    Dialog for letting the user select which atom-level property to show.
    """
    # Value will be property string, or None:
    atomPropertyChanged = QtCore.pyqtSignal(object)
[docs]    def initSetOptions(self):
        super().initSetOptions()
        self.std_btn_specs = {
            self.StdBtn.Ok: self.accept,
            self.StdBtn.Cancel: self.reject
        } 
[docs]    def __init__(self, viewer, annotate_property):
        self.viewer = viewer
        super().__init__(viewer)
        self.setWindowTitle("Choose Atom Property")
        self.label = QtWidgets.QLabel("Label atoms with property:")
        self.main_layout.addWidget(self.label)
        self.property_selector = propertyselector.PropertySelectorList(self)
        self.main_layout.addWidget(self.property_selector)
        self.property_selector.setProperties(viewer.all_atom_props)
        if annotate_property is not None:
            self.property_selector.selectProperty(annotate_property) 
[docs]    def accept(self):
        selected = self.property_selector.getSelected()
        if selected:
            self.atomPropertyChanged.emit(selected[0])
        else:
            self.atomPropertyChanged.emit(None)
        super().accept()  
[docs]class Cell:
    """
    The idea is to dynamically generate an instance whenever the model
    requests this data.
    """
[docs]    def __init__(self, st, has_protein, entry_id):
        """
        :param st: Structure to show in the cell (ligand only, no protein)
        :type st: `structure.Structure`
        :param has_protein: Whether to show the "protein" icon in the cell.
        :type has_protein: bool
        :param entry_id: Entry ID (when in Maestro) or Structure handle.
        :type entry_id: int
        """
        # Store ligand atoms only:
        self.st = st
        self.neut_st = None  # will be generated later
        self.has_protein = has_protein
        self.entry_id = entry_id
        # If aligned, (ref RDkit 2D conformer, ref core atoms, core atoms):
        self.alignment_info = None
        self.is_reference = False
        self.rotate_degrees = 0
        self.flip_vertical = False
        self.flip_horizontal = False
        self.atom_factors = []  # KPLS atomic factors
        # These 2 rectangles are used for mapping coordinates from mouse
        # pointer to coordinates in to the QPicture:
        self.pic_bounds_rect = None  # QPicture bounds rect
        # QRect where picture was drawn into (for mapping coordinates):
        self.pic_dest_rect = None  # QRect where picture was drawn to 
[docs]    def getProjectRow(self):
        """
        Return the ProjectRow instance for this entry.
        Returns None if the entry is no longer in the Project Table.
        """
        pt = maestro.project_table_get()
        return pt.getRow(self.entry_id) 
[docs]    def isIncluded(self):
        if self.entry_id:
            project_row = self.getProjectRow()
            if project_row is None:
                # Entry is no longer in project table.
                # TODO return a new state representing "disabled"
                return False
            return bool(project_row.in_workspace)
        else:
            return False 
[docs]    def getIncludedState(self):
        """
        Will return one of the following:
        NOT_IN_WORKSPACE, IN_WORKSPACE, LOCKED_IN_WORKSPACE
        """
        if self.entry_id:
            project_row = self.getProjectRow()
            if project_row is None:
                # Entry is no longer in project table.
                # TODO return a new state representing "disabled"
                return False
            return project_row.in_workspace
        else:
            return False 
[docs]    def clearAlignment(self):
        self.alignment_info = None
        self.is_reference = False
        self.clearTransformations() 
[docs]    def generateNeutralStateIfNeeded(self, uncharger):
        """
        Generate the neutral state of the structure, if one is still missing.
        """
        if not self.neut_st:
            try:
                mol = rdkit_adapter.to_rdkit(self.st,
                                             sanitize=True,
                                             implicitH=True)
                neut_mol = uncharger.uncharge(mol)
                neut_mol.UpdatePropertyCache(strict=False)
                self.neut_st = rdkit_adapter.from_rdkit(neut_mol)
            except Exception as e:
                # We just skip neutralization step on error.
                print("WARNING: Failed to neutralize CT: %s" % self.entry_id)
                print("  Exception: %s" % e)
                self.neut_st = self.st  
[docs]class TwoDTableModel(table.ViewerModel):
    """
    Custom model for 2D Viewer table
    The idea is for this model to dynamically change as the project table
    changes.
    """
    # TODO consider creating a proxy model class, which would take a
    # a linear (1-column) model and "map" it onto a multi-column proxy model,
    # for connecting to the view.
[docs]    def __init__(self, parent, *args):
        table.ViewerModel.__init__(self, *args)
        # List of Cell objects:
        self._cells = []
        self._num_cols = 3
        self.viewer = parent
        self._last_mouse_clicked_index = None 
[docs]    def removeReference(self):
        for cell in self._cells:
            cell.clearAlignment() 
[docs]    def clearReferenceHighlighting(self):
        """
        Clears reference ligand highlighting.
        Note: cells are still aligned to previous alignment, if any
        """
        for cell in self._cells:
            cell.is_reference = False 
[docs]    def setReferenceToIndex(self, ref_i):
        any_reset = False
        for i, cell in enumerate(self._cells):
            if cell.alignment_info:
                any_reset = True
            cell.alignment_info = None
            cell.is_reference = (i == ref_i)
        if any_reset:
            self.viewer.status("Reference changed - all orientations reset") 
[docs]    def alignCellsToReference(self, ref_i):
        """
        Set MCS/alignment reference to the given cell index, and align
        all cells to it.
        """
        self.setReferenceToIndex(ref_i)
        ref_cell = self.getCellFromI(ref_i)
        ref_st = self.viewer.getStructToRender(ref_cell)
        try:
            ref_rdmol = st_to_rdmol_without_hydrogens(ref_st)
        except Exception as err:
            show_warning(
                parent=None,
                text=
                f'Could not align structures because reference structure failed to render:\n{err}'
            )
            return
        cg_params = rdCoordGen.CoordGenParams()
        cg_params.treatNonterminalBondsToMetalAsZOBs = True
        # Generate a 2D conformer for the reference ligand:
        rdCoordGen.AddCoords(ref_rdmol, cg_params)
        ref_conf = ref_rdmol.GetConformer()
        num_cells = len(self._cells)
        progress = QtWidgets.QProgressDialog("Aligning 2D coordinates...",
                                             "Cancel", 1, num_cells,
                                             self.viewer)
        # Show dialog right away, in modal form:
        progress.setWindowModality(Qt.WindowModal)
        progress.show()
        for i, cell in enumerate(self._cells, start=1):
            if progress.wasCanceled():
                break
            st = self.viewer.getStructToRender(cell)
            try:
                rdmol = st_to_rdmol_without_hydrogens(st)
            except:
                progress.setValue(i)
                continue
            try:
                ref_core_atoms, core_atoms = get_mcs_atoms(ref_rdmol, rdmol)
            except RuntimeError as err:
                print('Error calculating MCS: %s' % err)
                cell.clearAlignment()
            else:
                # TODO consider saving SCHRODINGER_INDEX values instead
                cell.alignment_info = (ref_conf, ref_core_atoms, core_atoms)
                cell.copyTransformations(ref_cell)
                # NOTE: We are storing references to the Chem.rdchem.Conformer
                # to use it as a template when generating 2D images.
                # TODO refactor to generate 2D coordintes for all ligands here,
                # and cache them, for smoother scrolling of 2D Viewer window.
            progress.setValue(i)
        # Ensure that progress dialog is closed:
        progress.close() 
[docs]    def rowCount(self, parent=None):
        """
        Returns number of rows needed to display the data
        """
        count = ((len(self._cells) - 1) // self._num_cols) + 1
        return count 
[docs]    def getRowColumnFromI(self, i):
        row = (i // self._num_cols)
        col = i - (row * self._num_cols)
        return (row, col) 
[docs]    def getIndexFromI(self, i):
        assert i is not None
        (row, col) = self.getRowColumnFromI(i)
        return self.index(row, col) 
[docs]    def columnCount(self, parent=None):
        return self._num_cols 
[docs]    def flags(self, index):
        default_flags = super().flags(index)
        if index.isValid():
            # All non-placeholder cells can be dragged
            return Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled | default_flags
        else:
            # Place-holders can be also dragged into.
            return Qt.ItemIsDropEnabled | default_flags 
[docs]    def data(self, index, role=Qt.DisplayRole):
        """
        Use getCellFromIndex() instead
        """
        if not index.isValid():
            return None
        if role == ENTRY_ID_ROLE:
            i = self._getIFromIndex(index)
            try:
                return self._cells[i].entry_id
            except IndexError:
                # Place-holder cell at the end
                return None
        if role == CELL_ROLE:
            cell = self._getCellFromIndex(index)
            return cell
        elif role == MODEL_INDEX_ROLE:
            return self._getIFromIndex(index)
        # Fix for Ev:126134
        elif role == QtCore.Qt.DisplayRole:
            return ""
        return None 
    def _getCellFromIndex(self, index):
        """
        Return a Cell object for the given QModelIndex instance.
        """
        i = self._getIFromIndex(index)
        try:
            return self._cells[i]
        except IndexError:
            return None
    def _getIFromIndex(self, index):
        """
        Return the cell number for the given QModelIndex object.
        """
        i = index.row() * self._num_cols + index.column()
        return i
[docs]    def numCells(self):
        return len(self._cells) 
[docs]    def getCellFromI(self, i):
        try:
            return self._cells[i]
        except IndexError:
            return None 
[docs]    def getIFromCell(self, cell):
        try:
            i = self._cells.index(cell)
        except ValueError:
            return None
        return i 
[docs]    def setCells(self, cells):
        """
        Set the internal data list to the specified list of Cell objects.
        """
        self.modelAboutToBeReset.emit()
        self._cells = cells
        self.modelReset.emit() 
[docs]    def getEntryIds(self):
        """
        Return a list of entry IDs that are currently in the table.
        """
        return [cell.entry_id for cell in self._cells] 
[docs]    def getTitles(self):
        """
        Return a list of titles, for all structures. Each title is prepended
        with the row number of this entry (if in Maestro) or structure index
        (if outside of Maestro).
        """
        if self.viewer.enable_maestro_features:
            pt = maestro.project_table_get()
            row_iter = (pt[cell.entry_id] for cell in self._cells)
            titles = [
                '%i: %s' % (row.row_number, row.title) for row in row_iter
            ]
        else:
            st_iter = (cell.st for cell in self._cells)
            titles = [
                '%i: %s' % (i, st.title)
                for i, st in enumerate(st_iter, start=1)
            ]
        return titles 
[docs]    def removeAllRows(self):
        self.setCells([]) 
[docs]    def setColumns(self, new_num_cols):
        self.modelAboutToBeReset.emit()
        self._num_cols = new_num_cols
        self.modelReset.emit() 
[docs]    @table_helper.model_reset_method
    def removeIsFromTable(self, remove_is):
        """
        Remove cells with specified i's (cell index ints) from the table.
        """
        self._cells = [
            cell for i, cell in enumerate(self._cells) if i not in remove_is
        ] 
[docs]    def moveCell(self, old_index, new_index):
        """
        Move the given cell from old index (x,y position) to new index.
        """
        prev_i = self._getIFromIndex(old_index)
        new_i = self._getIFromIndex(new_index)
        self.modelAboutToBeReset.emit()
        cell = self._cells.pop(prev_i)
        self._cells.insert(new_i, cell)
        self.modelReset.emit() 
        # NOTE: No need to preserve selection, as the act of starting a
        # drag event clears any selection.
    def _getSingleIncludedI(self):
        """
        If only one of the cells is included in the Workspace, return the
        index for it. Otherwise returns None.
        """
        included_eids = maestro.get_included_entry_ids()
        included_i = None
        for i, cell in enumerate(self._cells):
            if cell.entry_id in included_eids:
                if included_i is not None:
                    # More than one included
                    return None
                included_i = i
        return included_i
[docs]    def handleInclusonToggled(self, index):
        """
        Called when inclusion toggle within a cell is clicked.
        :param index: Index of the clicked cell
        :type index: QModelIndex
        """
        application = QtWidgets.QApplication.instance()
        key_modifiers = application.keyboardModifiers()
        ctrl_down = bool(key_modifiers & Qt.ControlModifier)
        shift_down = bool(key_modifiers & Qt.ShiftModifier)
        self._handleInclusionToggled(index, ctrl_down, shift_down) 
    def _handleInclusionToggled(self, index, ctrl_down, shift_down):
        """
        :param index: Index of the clicked cell
        :type index: QModelIndex
        :param ctrl_down: Whether control/command key was held
        :type ctrl_down: bool
        :param shift_down: Whether shift key was held
        :type shift_down: bool
        """
        cell = index.data(CELL_ROLE)
        pt = maestro.project_table_get()
        save_clicked_cell = True
        if ctrl_down:
            project_row = cell.getProjectRow()
            if cell.isIncluded():
                # Exclude this cell only:
                project_row.in_workspace = False
                # Do not consider this click for next shift-click:
                save_clicked_cell = False
            else:
                # Include this cell (not affecting others):
                project_row.in_workspace = True
        elif shift_down:
            self.handleShiftClick(index)
        else:
            # No shift or control; include this entry and exclude others:
            cell.getProjectRow().includeOnly()
        # Unless de-selecting with control key held, use this click for next
        # shift-click event:
        if save_clicked_cell:
            self._last_mouse_clicked_index = QtCore.QPersistentModelIndex(index)
        else:
            self._last_mouse_clicked_index = None
[docs]    def handleShiftClick(self, index):
        """
        :param index: Index of the clicked cell
        :type index: QModelIndex
        """
        last_idx = self._last_mouse_clicked_index
        if last_idx and last_idx != index and last_idx.isValid():
            # If previously clicked entry is still present, start from it:
            start_i = self._getIFromIndex(last_idx)
        else:
            # If there is exactly one cell included, select range between
            # it and clicked cell:
            start_i = self._getSingleIncludedI()
        clicked_i = self._getIFromIndex(index)
        if start_i is not None:
            # Include a range of cells:
            first_i, last_i = sorted((start_i, clicked_i))
            include_is = range(first_i, last_i + 1)
        else:
            # Include only this single cell:
            include_is = [clicked_i]
        include_eids = [self.getCellFromI(i).entry_id for i in include_is]
        pt = maestro.project_table_get()
        pt.includeRows(include_eids, exclude_others=False)  
[docs]class TwoDTableView(table.DataViewerTable):
[docs]    def __init__(self, model, parent):
        table.DataViewerTable.__init__(self,
                                       model,
                                       parent,
                                       aspect_ratio=False,
                                       fill='columns',
                                       gang='none',
                                       resizable='none',
                                       fit_view='none',
                                       vscrollbar='default',
                                       hscrollbar='default',
                                       enable_copy=False)
        self.viewer = parent
        self.setDragEnabled(True)
        self.setDropIndicatorShown(True)
        self.setDragDropMode(QtWidgets.QTableView.DragDrop)
        # While dragging of a cell, gets set to the QModelIndex of the cell:
        self.dragging_cell_index = None 
[docs]    def columnWidth(self, colnum):
        table_width = self.viewport().width()
        col_width, col_remainder = divmod(table_width,
                                          self.model().columnCount())
        if colnum == self.model().columnCount() - 1:
            col_width += col_remainder
        return col_width 
[docs]    def rowHeight(self, rownum):
        """
        Returns the height of the specified row (same for all rows).
        This method is used by the view.
        """
        col_width = self.columnWidth(0)
        num_props = len(self.viewer.getPropertiesToShow())
        return col_width + (self.viewer.font_height * num_props) 
[docs]    def keyPressEvent(self, event):
        """
        Will get called when a key is pressed
        """
        # Ev:109449
        key = event.key()
        if key == Qt.Key_P or key == Qt.Key_N:
            # Ev:112447 pass this event to the panel's keyPressEvent()
            event.ignore()
        elif key == Qt.Key_PageUp:
            self.verticalScrollBar().triggerAction(
                QtWidgets.QAbstractSlider.SliderPageStepSub)
        elif key == Qt.Key_PageDown:
            self.verticalScrollBar().triggerAction(
                QtWidgets.QAbstractSlider.SliderPageStepAdd)
        else:
            return table.DataViewerTable.keyPressEvent(self, event) 
[docs]    def resizeEvent(self, event):
        """
        Called when the viewport changes size. Resizes the rows to keep
        images square.  Uses view's rowHeight() method.
        """
        ret = super().resizeEvent(event)
        self.resizeRowsToContents()
        return ret 
[docs]    def dragEnterEvent(self, event):
        self.dragging_cell_index = self.indexAt(event.pos())
        return super().dragEnterEvent(event) 
[docs]    def dropEvent(self, event):
        """
        Don't allow drops to the right of the right-most column.  See Qt
        documentation for additional method documentation.
        """
        assert self.dragging_cell_index
        replace_index = self.indexAt(event.pos())
        if self.dragging_cell_index == replace_index:
            return
        self.model().moveCell(self.dragging_cell_index, replace_index)
        self.dragging_cell_index = None
        return super().dropEvent(event)  
[docs]class TwoDTableDelegate(table.GenericViewerDelegate):
[docs]    def __init__(self, viewer, tableview, tablemodel):
        table.GenericViewerDelegate.__init__(self, tableview, tablemodel)
        self.viewer = viewer
        self.view = tableview 
[docs]    def paint(self, painter, option, index):
        # Draw the background according to the selection state on Windows
        # (Ev:124240):
        QtWidgets.QItemDelegate.paint(self, painter, option, index)
        painter.setBrush(QtGui.QColor(0, 0, 0))
        entry_id = index.data(ENTRY_ID_ROLE)
        if not entry_id:
            # Skip painting if this is a placeholder cell.
            return
        cell = index.data(CELL_ROLE)
        pic = self.viewer.generatePicFromCell(cell)
        self.viewer.paintOneCell(painter, cell, pic, option.rect, False) 
 
[docs]class TwoDViewer(QtWidgets.QFrame):
    """
    Embeddable 2D Viewer, that contains:
    1) Main table, with a vertical scroll bar
    2) "Change view" pull-down menu
    3) Text "Drag cells to reorder.  Right-click for more options."
    4) Reference pull-down menu with "Align All" button
    5) Transformation options
    6) Generate report options
    :ivar enable_maestro_features: Enables maestro connected features, inclusion in project table
        and import functionality
    :vartype enable_maestro_features: bool
    """
    enable_maestro_features: bool = bool(maestro)
[docs]    def __init__(self, parent=None):
        super().__init__(parent)
        if parent and parent.window().__class__.__name__ == 'TwoDViewerPanel':
            self.panel = parent.window()
        else:
            self.panel = None
        self.ui = two_d_viewer_ui.Ui_Form()
        self.ui.setupUi(self)
        self.populateViewMenu()
        self.ui.show_hide_btn.toggled.connect(self.onShowHideActionsToggled)
        self.ui.actions_frame.setStyleSheet("background-color:#D9D9D9;")
        self.populateSaveExportMenu()
        self.setupPanel()
        self.kpls_vis = None
        self.display_property_names = True
        self.use_title_caps = False
        # Atom-level property to use for annotation. None means element symbol
        self.annotate_property = None
        self.max_atoms = 200
        # FIXME use maestro.get_command_option( "prefer", "2dmaxatoms" )
        # Linked to max_scale. Small values mean benzenes appear smaller:
        # Ev:79334 So that small molecules do not have extra long bonds:
        self.max_scale_factor = 0.2
        self.all_props = set()
        self.selected_props = []
        # Atom-level properties that can be used for annotation
        self.all_atom_props = []
        self.precisions = {}
        if self.enable_maestro_features:
            self.synchronizePreferencesFromMaestro()
            maestro.command_callback_add(self.commandCallback) 
[docs]    def onShowHideActionsToggled(self, checked):
        """
        Show or hide the actions section.
        """
        self.ui.actions_frame.setVisible(checked)
        self.ui.actions_frame.adjustSize()
        if checked:
            btn_text = HIDE_ACTIONS_TEXT
        else:
            btn_text = MORE_ACTIONS_TEXT
        self.ui.show_hide_btn.setText(btn_text)
        self.repaint() 
[docs]    def neutralizeToggled(self, on):
        """
        Called when the "Show Neutral Form" menu item is toggled.
        """
        if on:
            self.generateNeutralizedStates()
        self.refreshTable() 
[docs]    @wait_cursor
    def generateNeutralizedStates(self):
        """
        For each loaded structure, generate the neuralized form, if not
        already present.
        """
        uncharger = rdMolStandardize.Uncharger(canonicalOrder=True)
        for cell in self.getAllCells():
            cell.generateNeutralStateIfNeeded(uncharger) 
[docs]    def linkSelectionAndInclusionToggled(self, on):
        if on:
            self.includeSelectedCellsEntries() 
[docs]    def displayPropsDialog(self):
        """
        Open CT properties dialog box.
        """
        dialog = CtPropsDialog(self, self.all_props, self.selected_props,
                               self.display_property_names, self.use_title_caps)
        dialog.ctPropertiesChanged.connect(self.showPropNames)
        dialog.exec() 
[docs]    def showPropNames(self, selected_props, show_names, title_caps):
        self.selected_props = selected_props
        self.display_property_names = show_names
        self.use_title_caps = title_caps
        if selected_props:
            self.show_entry_props_action.setChecked(True)
        self.propsChanged() 
[docs]    def getPropertiesToShow(self):
        """
        Return a list of (property data name, whether to include property name)
        for each property that should be rendered below the 2D image.
        """
        if self.show_entry_props_action.isChecked():
            return self.selected_props
        else:
            return [] 
[docs]    def atomPropDialog(self):
        """
        Open atom annotation dialog box.
        """
        dialog = AtomPropertyDialog(self, self.annotate_property)
        dialog.atomPropertyChanged.connect(self.atomPropChanged)
        dialog.exec() 
[docs]    def atomPropChanged(self, prop):
        self.annotate_property = prop
        if prop:
            self.show_atom_anns_action.setChecked(True)
        self.propsChanged() 
[docs]    def openDisplayPreferences(self):
        """
        Open the Maestro's preference dialog, to the image display tab.
        """
        maestro.command("showpanel prefer:2dstructure") 
[docs]    def columnActionSelected(self, item_action):
        num_cols = item_action.data()
        self.setNumColumns(num_cols)
        item_action.setChecked(True) 
[docs]    def synchronizePreferencesFromMaestro(self):
        """
        Update the 2D Viewer's preferences to the Maestro's preferences.
        """
        # Ev:104410
        # NOTE: Please put in an effort to make this method as fast as possible.
        self.max_atoms = int(maestro.get_command_option("prefer", "2dmaxatoms"))
        self.max_scale_factor = float(
            maestro.get_command_option("prefer", "2dmaxscalefactor"))
        self.setWaitCursor()
        # Re-draw visible cells:
        self.refreshTable()
        self.restoreCursor() 
[docs]    @qt_utils.remove_wait_cursor
    def info(self, text, preferences=None, key=""):
        messagebox.show_info(parent=self, text=text, save_response_key=key) 
[docs]    @qt_utils.remove_wait_cursor
    def warning(self, text, preferences=None, key=""):
        messagebox.show_warning(parent=self, text=text, save_response_key=key) 
[docs]    def setWaitCursor(self):
        self.setCursor(QtGui.QCursor(Qt.WaitCursor)) 
[docs]    def restoreCursor(self):
        self.setCursor(QtGui.QCursor(Qt.ArrowCursor)) 
[docs]    def status(self, text=''):
        """
        Set the status label to the specified text.
        """
        if self.panel:
            self.panel.status(text) 
[docs]    def commandCallback(self, command):
        """
        Called by Maestro when a command is issued, and is used to update
        the 2D Viewer preferences when Maestro's preferences change.
        """
        # Ev:104410
        s = command.split()
        if s[0] == "prefer":
            option = s[1].split('=')[0]
            if option in [
                    "2dmaxatoms", "2dmaxscalefactor", "2dfontsize",
                    "2dbondlinewidth", "2dhashspacing", "2dbondspacing",
                    "2dusecolor", "2dshowimplicithydrogens", "2dlabelallcarbons"
            ]:
                self.synchronizePreferencesFromMaestro() 
[docs]    @af2.maestro_callback.workspace_changed
    def workspaceChanged(self, what_changed):
        if self.ignore_project_update:
            # This change was triggered by 2D Viewer itself
            return
        if what_changed not in (maestro.WORKSPACE_CHANGED_EVERYTHING,
                                maestro.WORKSPACE_CHANGED_APPEND,
                                maestro.WORKSPACE_CHANGED_CONNECTIVITY):
            return
        if not self.ui.auto_update_cb.isChecked():
            return
        included_eids = maestro.get_included_entry_ids()
        if not included_eids:
            return
        pt = maestro.project_table_get()
        maestro.project_table_synchronize()
        for i, cell in enumerate(self.getAllCells()):
            eid = cell.entry_id
            if eid in included_eids:
                st = pt[eid].getStructure(workspace_sync=False)
                new_cell = self.generateCellForEntry(eid, st)
                self.table_model._cells[i] = new_cell
        # Redraw the visible cells:
        self.table_view.viewport().repaint() 
[docs]    @wait_cursor
    def copyToClipboard(self, cell):
        pic = self.generatePicFromCell(cell)
        img = self.getImageFromPic(pic)
        application = QtWidgets.QApplication.instance()
        clipboard = application.clipboard()
        clipboard.setImage(img) 
[docs]    def setupPanel(self):
        """ Setup the GUI """
        self.included_pixmap = QtGui.QPixmap(
            ":/pt_gui_dir/icons/entry-active-included.png")
        self.excluded_pixmap = QtGui.QPixmap(
            ":/pt_gui_dir/icons/entry-active-excluded.png")
        self.included_fixed_pixmap = QtGui.QPixmap(
            ":/pt_gui_dir/icons/entry-active-fixed-in-project.png")
        self.protein_present_pixmap = QtGui.QPixmap(
            ":/msv/icons/struct_included_light.png")
        self.ui.reference_combo.currentIndexChanged.connect(
            self.referenceChanged)
        self.ui.align_all_btn.clicked.connect(self.alignAllToReference)
        self.ui.previous_button.clicked.connect(self.previousStructure)
        self.ui.next_button.clicked.connect(self.nextStructure)
        self.ui.rotate_left_btn.clicked.connect(self.rotateLeft)
        self.ui.rotate_right_btn.clicked.connect(self.rotateRight)
        self.ui.flip_horizontal_btn.clicked.connect(self.flipHorizontal)
        self.ui.flip_vertical_btn.clicked.connect(self.flipVertical)
        self.ui.rotate_left_btn.setIcon(get_icon('rotate-counterclock-15.png'))
        self.ui.rotate_right_btn.setIcon(get_icon('rotate-clockwise-15.png'))
        self.ui.flip_horizontal_btn.setIcon(get_icon('flip-horizontal.png'))
        self.ui.flip_vertical_btn.setIcon(get_icon('flip-vertical.png'))
        # Create zoom widget:
        self.zoom_view = ZoomWidget(self)
        self.zoom_view.installEventFilter(self)
        self.zoom_view.setContextMenuPolicy(Qt.CustomContextMenu)
        self.zoom_view.customContextMenuRequested.connect(
            self.zoomContextMenuRequested)
        self.ui.stacked_widget.addWidget(self.zoom_view)
        self.zoom_i = None
        self.table_model = TwoDTableModel(self)
        self.table_view = TwoDTableView(self.table_model, self)
        self.table_view.setStyleSheet('gridline-color: #808080;')
        view_font = self.table_view.viewOptions().font
        self.font_height = QtGui.QFontMetrics(view_font).height()
        self.table_view.setContextMenuPolicy(Qt.CustomContextMenu)
        self.table_view.customContextMenuRequested.connect(
            self.gridContextMenuRequested)
        self.table_view.viewport().installEventFilter(self)
        self.ui.stacked_widget.addWidget(self.table_view)
        self.ui.stacked_widget.setCurrentWidget(self.table_view)
        self.table_delegate = TwoDTableDelegate(self, self.table_view,
                                                self.table_model)
        self.table_delegate.setPaintWait(True)
        self.table_view.setItemDelegate(self.table_delegate)
        self.table_view.selectionModel().selectionChanged.connect(
            self.tableSelectionChanged)
        self.table_view.doubleClicked.connect(self.cellDoubleClicked)
        # Hide the headers:
        vh = self.table_view.verticalHeader()
        vh.setVisible(False)
        hh = self.table_view.horizontalHeader()
        hh.setVisible(False)
        # Disable the prev/next buttons:
        self.updatePreviousAndNextButtons()
        # Initialize self.table_view FIXME
        # multi select, don't show title bar
        # Add 3 columns
        # TODO use flag_context_manager for these in the future
        self.ignore_selection_changed = False
        self.ignore_project_update = False 
[docs]    def eventFilter(self, obj, event):
        if event.type() not in (QtCore.QEvent.MouseButtonPress,
                                QtCore.QEvent.ToolTip):
            return False
        if obj == self.zoom_view:
            localx = event.pos().x()
            localy = event.pos().y()
            item_rect = self.zoom_view.rect()
            assert self.zoom_i is not None
            cell = self.getZoomCell()
            index = self.table_model.getIndexFromI(self.zoom_i)
        elif obj == self.table_view.viewport():
            index = self.table_view.indexAt(event.pos())
            if not index.isValid():
                return False
            cell = index.data(CELL_ROLE)
            if cell is None:
                # Place-holder cell, ignore events
                return False
            item_rect = self.table_view.visualRect(index)
            localx = event.pos().x() - item_rect.left()
            localy = event.pos().y() - item_rect.top()
        else:
            return False
        if event.type() == QtCore.QEvent.MouseButtonPress:
            if event.button() == Qt.LeftButton:
                pass_on_event = self.cellClicked(index, localx, localy)
                if obj == self.zoom_view:
                    self.zoom_view.repaint()
                return not pass_on_event
            elif event.button() == Qt.RightButton:
                # If this cell is already selected, then prevent the view from
                # de-selecting the other cells before showing the contextual
                # menu. If this cell is NOT selected, then we do want the view
                # to select it and deselect the other cells first.
                ignore_event = cell.entry_id in self.getSelectedEntryIds()
                return ignore_event
        if event.type() == QtCore.QEvent.ToolTip:
            help_event = QtGui.QHelpEvent(event)
            # Figure out whether the cursor is in the "included" icon:
            if self.enable_maestro_features:
                incmin = self.getIncludeToggleOffset()
                incmax = incmin + 12  # included icon is 12x12
                if incmin <= localx <= incmax and incmin <= localy <= incmax:
                    state = cell.getIncludedState()
                    if state == project.NOT_IN_WORKSPACE:
                        tooltip_text = "Excluded from Workspace"
                    elif state == project.LOCKED_IN_WORKSPACE:
                        tooltip_text = "Locked in Workspace"
                    elif state == project.IN_WORKSPACE:
                        tooltip_text = "Included in Workspace"
                    QtWidgets.QToolTip.showText(help_event.globalPos(),
                                                tooltip_text)
                    return True  # handled event
            property_matrix = self.calculatePropertiesCoordinates(
                cell, item_rect)
            # Figure out which property the cursor is in:
            for username, value, top, bottom in property_matrix:
                if localy > top and localy < bottom:
                    tooltip_text = "%s: %s" % (username, value)
                    QtWidgets.QToolTip.showText(help_event.globalPos(),
                                                tooltip_text)
                    return True  # handled event
            # Figure out whether cursor is over an atom that has an annotation:
            if obj == self.zoom_view and cell.atom_factors:
                pos = QtCore.QPoint(localx, localy)
                tooltip = self.getTooltipForPosition(pos, self.getZoomCell())
                if tooltip:
                    QtWidgets.QToolTip.showText(help_event.globalPos(), tooltip)
                    return True  # handled event
            # Hide the previous tool-tip:
            QtWidgets.QToolTip.hideText()
            event.ignore()
            return True
        return False 
[docs]    def getIncludeToggleOffset(self):
        if self.inZoomMode():
            return 10
        else:
            return 3 
[docs]    def paintOneCell(self, painter, cell, pic, cell_rect, exporting):
        """
        Paint the given QPicture of the 2D structure, plus properties and
        inclusion icon to the specified painter.
        NOTE: For large structures, pic will contain rendering of text
        "Too many atoms to display: X" instead of the 2D image.
        If the structure failed to render, the pic will contain only
        white background (as of 9/11/19).
        """
        global_left = cell_rect.left()
        global_top = cell_rect.top()
        # Go to cell's reference frame:
        painter.translate(global_left, global_top)
        cell_right = cell_rect.width()
        cell_bottom = cell_rect.height()
        props_height = (self.font_height * len(self.getPropertiesToShow()))
        if pic is not None:
            # If not scrolling
            pic_w = cell_right
            pic_h = cell_bottom - props_height
            dest_rect = QtCore.QRect(0, 0, pic_w, pic_h)
            cell.pic_dest_rect = swidgets.draw_picture_into_rect(
                painter, pic, dest_rect, self.max_scale_factor, PADDING_FACTOR)
            cell.pic_bounds_rect = pic.boundingRect()
        toffset = self.getIncludeToggleOffset()
        # Create include/exclude toggle at top-left corner of the canvas:
        if self.enable_maestro_features and not exporting:
            state = cell.getIncludedState()
            if state == project.NOT_IN_WORKSPACE:
                pixmap = self.excluded_pixmap
            elif state == project.LOCKED_IN_WORKSPACE:
                pixmap = self.included_fixed_pixmap
            elif state == project.IN_WORKSPACE:
                pixmap = self.included_pixmap
            # NOTE: Icons are 24x24 in size, so using 12x12 here renders
            # them without scaling on Retina displays.
            painter.drawPixmap(toffset, toffset, 12, 12, pixmap)
        if cell.has_protein:
            # This icon is 100x100 in size, scale to 20x20 (40x40 on retina).
            # Any smaller size makes this icon look worse.
            size = 20
            x = cell_right - toffset - size
            painter.drawPixmap(x, toffset, size, size,
                               self.protein_present_pixmap)
        # Draw the properties:
        property_matrix = self.calculatePropertiesCoordinates(cell, cell_rect)
        # First fill background of the properties in gray. This needs to be
        # done first, so that horizontal lines are drawn on top.
        if property_matrix:
            _, _, props_top, _ = property_matrix[0]
            _, _, _, props_bottom = property_matrix[-1]
            props_height = props_bottom - props_top
            bg_rect = QtCore.QRect(0, props_top, cell_right, props_height)
            painter.fillRect(bg_rect, PROP_BACKGROUND_COLOR)
            # Figure out which property the cursor is in:
            for prop_uname, value, prop_top, prop_bottom in property_matrix:
                prop_height = prop_bottom - prop_top
                # Draw a horizontal line at the bottom edge of the prop rect:
                painter.setPen(PROP_LINE_COLOR)
                painter.drawLine(0, prop_bottom, cell_right, prop_bottom)
                painter.drawLine(cell_right, prop_top, cell_right, prop_bottom)
                middle = cell_right / 2
                value_text = str(value)
                painter.setPen(TEXT_COLOR)
                margin = 4  # property margin
                if self.display_property_names:
                    if self.use_title_caps:
                        prop_uname = prop_uname.upper()
                    width = middle - (2.0 * margin)
                    painter.drawText(margin, prop_top, width, prop_height, 0,
                                     prop_uname)
                    painter.drawText(middle + margin, prop_top, width,
                                     prop_height, 0, value_text)
                else:
                    width = cell_right - (2.0 * margin)
                    painter.drawText(margin, prop_top, width, prop_height, 0,
                                     value_text)
        # Go back to the global reference frame:
        painter.translate(-global_left, -global_top) 
[docs]    def getPropsToShowForCell(self, cell):
        """
        Return a list of (property name, value) for properties to be reported
        for the given cell.
        """
        if not self.show_entry_props_action.isChecked():
            return []
        props = []
        for i, dataname in enumerate(self.selected_props):
            username = structure.PropertyName(dataname).userName()
            value = cell.st.property.get(dataname, 'None')
            if value != 'None' and self.precisions and dataname.startswith(
                    "r_"):
                precision = self.precisions[dataname]
                value = round(value, precision)
            props.append((username, value))
        return props 
[docs]    def calculatePropertiesCoordinates(self, cell, rect):
        """
        Returns a list of (propname, value, top_y, bottom_y) for all
        properties that are to be displayed to the cell.
        This information is then used for drawing properties and for displaying
        tool tips.
        # Ev:91560
        """
        props = self.getPropsToShowForCell(cell)
        prop_height = self.font_height
        cell_height = rect.height()
        # Go to cell's reference frame:
        props_height = (prop_height * len(props))
        top_of_props = cell_height - props_height
        property_matrix = []
        # Draw the property texts:
        for i, (prop_uname, value) in enumerate(props):
            prop_top = top_of_props + (i * prop_height)
            prop_bottom = prop_top + prop_height
            property_matrix.append((prop_uname, value, prop_top, prop_bottom))
        return property_matrix 
[docs]    def keyPressEvent(self, event):
        """
        Will get called when a key is pressed
        """
        key = event.key()
        if key == Qt.Key_M:
            self.mark()
        elif key == Qt.Key_P:
            self.previousStructure()
        elif key == Qt.Key_N:
            self.nextStructure()
        elif key in (Qt.Key_PageUp, Qt.Key_Left) and self.inZoomMode():
            self.previousStructure()
        elif key in (Qt.Key_PageDown, Qt.Key_Right) and self.inZoomMode():
            self.nextStructure()
        else:
            super().keyPressEvent(event) 
[docs]    def mark(self, ignored=None):
        cell_to_mark = None
        if self.inZoomMode():
            cell_to_mark = self.getZoomCell()
        else:
            included_cells = []
            num_rows = self.table_model.rowCount()
            num_cols = self.table_model.columnCount()
            for rowi in range(num_rows):
                for coli in range(num_cols):
                    index = self.table_model.index(rowi, coli)
                    cell = index.data(CELL_ROLE)
                    if cell:
                        if cell.isIncluded():
                            included_cells.append(cell)
            if len(included_cells) == 0:
                return
            elif len(included_cells) == 1:
                cell_to_mark = included_cells[0]
            else:  # More than one included
                num = self.numSelectedCells()
                if num == 1:
                    cell_to_mark = self.getSelectedCell()
        if cell_to_mark:
            row = cell_to_mark.getProjectRow()
            if row is None:
                self.warning('Entry is no longer in the Project Table.')
                return
            prev = row['b_m_pose_viewer_tag']
            # If was None or False, will set to True:
            row['b_m_pose_viewer_tag'] = not prev
            pt = maestro.project_table_get()
            pt.update() 
[docs]    def setNumColumns(self, new_num_cols):
        old_num = self.table_model.columnCount()
        if new_num_cols == old_num:
            return
        self.setWaitCursor()
        self.status("Changing the number of columns...")
        self.ignore_selection_changed = True
        # Save old selection:
        selected_is = []
        for i in self.iterateOverSelectedIs():
            selected_is.append(i)
        # Change the number of columns:
        self.table_model.setColumns(new_num_cols)
        # Show the table and hide place-holder:
        self.table_view.resizeRowsToContents()
        self.table_view.resizeColumnsToContents()
        # Restore the selection:
        if selected_is:
            index_to_scroll_to = None
            new_selection = QtCore.QItemSelection()
            for i in selected_is:
                index = self.table_model.getIndexFromI(i)
                new_selection.select(index, index)
                if not index_to_scroll_to:
                    index_to_scroll_to = index
            selection_model = self.table_view.selectionModel()
            selection_model.select(new_selection,
                                   QtCore.QItemSelectionModel.ClearAndSelect)
            if index_to_scroll_to is not None:
                self.table_view.scrollTo(index_to_scroll_to)
        self.ignore_selection_changed = False
        self.restoreCursor()
        self.status() 
[docs]    def cellDoubleClicked(self, item):
        # Ev:72224 Don't zoom when double clicking a cell:
        return 
[docs]    def toggleZoomView(self):
        if self.inZoomMode():
            self.exitZoomView()
        else:
            self.enterZoomView() 
[docs]    def previousStructure(self):
        if not self.ui.previous_button.isEnabled():
            return  # Ev:107285
        index = self.table_view.selectedIndexes()[0]
        i = index.data(MODEL_INDEX_ROLE)
        if i != 0:
            # If this is not the first cell
            self.selectOnlyCellI(i - 1) 
[docs]    def nextStructure(self):
        if not self.ui.next_button.isEnabled():
            return  # Ev:107285
        index = self.table_view.selectedIndexes()[0]
        i = index.data(MODEL_INDEX_ROLE)
        if i != (self.table_model.numCells() - 1):
            # If this is not the last cell
            self.selectOnlyCellI(i + 1) 
[docs]    def rotateLeft(self):
        for cell in self.getAllCells():
            if cell.flip_horizontal != cell.flip_vertical:  # XOR
                cell.rotate_degrees += 15.0
            else:
                cell.rotate_degrees -= 15.0
        self.refreshTable() 
[docs]    def rotateRight(self):
        for cell in self.getAllCells():
            if cell.flip_horizontal != cell.flip_vertical:  # XOR
                cell.rotate_degrees -= 15.0
            else:
                cell.rotate_degrees += 15.0
        self.refreshTable() 
[docs]    def flipHorizontal(self):
        for cell in self.getAllCells():
            cell.flip_horizontal = not cell.flip_horizontal
        self.refreshTable() 
[docs]    def flipVertical(self):
        for cell in self.getAllCells():
            cell.flip_vertical = not cell.flip_vertical
        self.refreshTable() 
[docs]    def selectOnlyCellIndex(self, index):
        """
        Select only the cell with the given index
        """
        i = index.data(MODEL_INDEX_ROLE)
        self.selectOnlyCellI(i) 
[docs]    def selectOnlyCellI(self, i):
        if self.inZoomMode():
            self.zoom_i = i
        # Whether in zoom view or not, change table selection:
        selection_model = self.table_view.selectionModel()
        index = self.table_model.getIndexFromI(i)
        selection_model.select(index, QtCore.QItemSelectionModel.SelectCurrent)
        # Scroll to new selection:
        self.table_view.scrollTo(index)
        i = index.data(MODEL_INDEX_ROLE)
        # In case we reached first/last structure:
        if i == 0:
            self.ui.previous_button.setEnabled(False)
        else:
            self.ui.previous_button.setEnabled(True)
        if i == self.table_model.numCells() - 1:
            self.ui.next_button.setEnabled(False)
        else:
            self.ui.next_button.setEnabled(True)
        if self.inZoomMode():
            # Ev:87002 Must repaint at very end:
            self.zoom_view.repaint()
            # When in zoom view, show groups of the zoomed entry:
            self.showZoomCellStatus()
        else:
            self.status() 
[docs]    def showZoomCellStatus(self):
        """
        Show the group info in the status field.
        """
        if not self.inZoomMode() or not maestro:
            return
        row = self.getZoomCell().getProjectRow()
        if row is None:
            # Entry is no longer in PT
            self.status('No longer in Project Table')
            return
        group = row.group
        if group is None:
            self.status('Group: N/A')
        else:
            groups = [group.title]
            parent_group = group.getParentGroup()
            while parent_group is not None:
                groups.insert(0, parent_group.title)
                parent_group = parent_group.getParentGroup()
            group_txt = ' > '.join(groups)
            # TODO truncate from left
            self.status('Group: %s' % group_txt) 
[docs]    def enterZoomView(self):
        if self.table_model.numCells() == 0:
            self.warning("No cell found to zoom into")
            self.single_view_action.setChecked(False)
            return
        # Zoom into first selected cell, or if there is no selection, first
        # cell of the table
        selected_indices = self.table_view.selectedIndexes()
        if selected_indices:
            index = selected_indices[0]
        else:
            # No selection - view first item:
            index = self.table_model.index(0, 0)
        if len(selected_indices) != 1:
            # Select the cell we are zooming into:
            self.selectOnlyCellIndex(index)
        self.zoom_i = index.data(MODEL_INDEX_ROLE)
        self.ui.stacked_widget.setCurrentWidget(self.zoom_view)
        self.grid_layout_action.setChecked(False)
        self.single_view_action.setChecked(True)
        self.updatePreviousAndNextButtons()
        self.column_submenu.setEnabled(False)
        self.ui.instruction_label.setText('Right-click for more options.')
        self.ui.previous_button.show()
        self.ui.next_button.show() 
[docs]    def exitZoomView(self):
        self.ui.stacked_widget.setCurrentWidget(self.table_view)
        self.zoom_i = None
        self.single_view_action.setChecked(False)
        self.grid_layout_action.setChecked(True)
        self.column_submenu.setEnabled(True)
        self.ui.instruction_label.setText(
            'Drag cells to reorder.  Right-click for more options.')
        self.ui.previous_button.hide()
        self.ui.next_button.hide() 
[docs]    def inZoomMode(self):
        """
        Return True if currently in zoom (single structure) mode.
        """
        return self.zoom_i != None 
[docs]    def getZoomCell(self):
        """
        Return the Cell object for the currently zoomed cell.
        """
        assert self.zoom_i is not None
        return self.table_model.getCellFromI(self.zoom_i) 
[docs]    def tableSelectionChanged(self, selected, deselected):
        """
        Called by Qt when table selection is changed
        """
        if self.ignore_selection_changed:
            # Do nothing if we updated the table ourselves
            return
        # Make sure that place-holder was not selected:
        selection_model = self.table_view.selectionModel()
        deselect_selection = QtCore.QItemSelection()
        placeholders_found = False
        last_row = self.table_model.rowCount() - 1
        for index in self.table_view.selectionModel().selectedIndexes():
            if index.row() == last_row:
                cell = index.data(CELL_ROLE)
                if cell is None:
                    # Deselect this place-holder cell:
                    deselect_selection.select(index, index)
                    placeholders_found = True
        if placeholders_found:
            # We do not want this to trigger a selection changed event:
            self.ignore_selection_changed = True
            # Invert selection of these cells:
            selection_model.select(deselect_selection,
                                   QtCore.QItemSelectionModel.Toggle)
            self.ignore_selection_changed = False
        if self.enable_maestro_features and self.link_sel_and_inc_action.isChecked(
        ):
            # Select newly included entries
            self.includeSelectedCellsEntries()
        self.updatePreviousAndNextButtons()
        self.updateInfoLabel() 
[docs]    def includeSelectedCellsEntries(self):
        """
        Include all selected cell's entries in the Workspace, and exclude
        any other entries.
        """
        pt = maestro.project_table_get()
        eids_included_in_pt = set(maestro.get_included_entry_ids())
        eids_selected_in_2d_viewer = self.getSelectedEntryIds()
        if eids_included_in_pt == set(eids_selected_in_2d_viewer):
            return
        num_selected = len(eids_selected_in_2d_viewer)
        # Show a warning if including > 100 structures; but do not show the
        # warning if just including a few more structures than before:
        if num_selected > 100 and num_selected > len(eids_included_in_pt) + 10:
            msg = "Do you really want to include %i entries in Workspace?" % num_selected
            result = maestro.question(msg,
                                      button1="Include entries",
                                      button2="No")
            if result == maestro.BUTTON2:
                self.restoreCursor()
                self.status()
                return
        with wait_cursor:
            self.status("Including selected cell's entries...")
            # Do not run projectUpdated() within this method:
            self.ignore_project_update = True
            pt.includeRows(eids_selected_in_2d_viewer, exclude_others=True)
            # Find first entry that was just now included (and previously
            # wasn't included) and scroll PT to show it, if it was previously
            # out of view:
            for eid in eids_selected_in_2d_viewer:
                if not eid in eids_included_in_pt:
                    # Emit scrollToEntry signal to update HPT
                    maestrohub = maestro_ui.MaestroHub.instance()
                    maestrohub.scrollToEntryID.emit(int(eid))
                    # TODO consider moving this into Project.includeRows()
                    break
            # Redraw 2D viewer:
            self.table_view.viewport().update()
            # Update the Project Table for changes to take effect:
            self.ignore_project_update = False
            self.status() 
[docs]    def updateInfoLabel(self):
        """
        Update the label that shows how many structure are loaded, and how
        many are selected.
        """
        if not self.panel:
            return
        num_selected = len(self.table_view.selectionModel().selectedIndexes())
        num_total = self.table_model.numCells()
        text = '%i of %i structures selected' % (num_selected, num_total)
        self.panel.ui.info_label.setText(text) 
[docs]    def showEntryIds(self, entry_ids_to_show):
        """
        Populate the table with the given entry IDs.
        """
        if self.inZoomMode():
            # If in zoom view:
            self.exitZoomView()
        pt = maestro.project_table_get()
        self.all_props = set(pt.getPropertyNames())
        self.all_atom_props = set()
        if entry_ids_to_show:
            # Get a list of atom-level properties
            first_eid = entry_ids_to_show[0]
            st = pt[first_eid].getStructure()
            self.all_atom_props.update(
                st.getAtomPropertyNames(include_builtin=True))
        previously_selected_eids = [
            index.data(ENTRY_ID_ROLE)
            for index in self.table_view.selectionModel().selectedIndexes()
        ]
        num_cells = len(entry_ids_to_show)
        progress = QtWidgets.QProgressDialog("Updating...", "Cancel", 1,
                                             num_cells, self)
        # Show dialog right away, in modal form:
        progress.setWindowModality(Qt.WindowModal)
        progress.show()
        canceled = False
        pt = maestro.project_table_get()
        maestro.project_table_synchronize()
        select_i_list = []
        def st_iterator():
            for i, eid in enumerate(entry_ids_to_show):
                if progress.wasCanceled():
                    canceled = True
                    break
                st = pt[eid].getStructure(workspace_sync=False)
                yield st, eid
                if eid in previously_selected_eids:
                    select_i_list.append(i)
                progress.setValue(i + 1)
        self.populateTableFromStructures(st_iterator())
        if canceled:
            # 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)
        # 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)
        # Ensure that progress dialog is closed:
        progress.close()
        self.status() 
[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 = []
        if infile:
            st_count = structure.count_structures(infile)
            progress = QtWidgets.QProgressDialog("Loading structures...",
                                                 "Cancel", 1, st_count, self)
            progress.setWindowModality(Qt.WindowModal)
            progress.show()
            def st_iterator():
                st_num = 0
                for st in structure.StructureReader(infile):
                    st_num += 1
                    if progress.wasCanceled():
                        break  # Exit loop
                    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
                    progress.setValue(st_num)
                    if progress.wasCanceled():
                        break
            self.populateTableFromStructures(st_iterator())
            # Ensure that progress dialog is closed:
            progress.close()
        self.status()
        return True 
[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()