"""
Common widgets and code for builders such as the TM Complex Builder and the
Oligamer builder
Copyright Schrodinger, LLC. All rights reserved.
"""
import abc
import glob
import os.path
import inflect
from pathlib import Path
import schrodinger
from schrodinger import structure
from schrodinger.application.matsci import buildcomplex
from schrodinger.application.matsci import coarsegrain
from schrodinger.application.matsci import msutils
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.rdkit import rgroup
from schrodinger.structutils import analyze
from schrodinger.structutils import build
from schrodinger.structutils import minimize
from schrodinger.structutils import transform
from schrodinger.thirdparty import rdkit_adapter
from schrodinger.ui import sketcher
from schrodinger.ui.qt import decorators
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import filter_list
from schrodinger.ui.qt import structure2d
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qtutils
from schrodinger.utils import fileutils
from schrodinger.utils import preferences
maestro = schrodinger.get_maestro()
TEMPLATE_EXTENSIONS = {'.mae', '.mae.gz', '.maegz'}
TEMPLATE_EXTENSION = '.maegz'
TEMPLATE_GLOB = '*' + TEMPLATE_EXTENSION
RECENT_TEMPLATE_FILE = 'recent.txt'
DEFAULT_TEMPLATE = 'H'
CUSTOM_TEMPLATE = 'Custom'
NO_TEMPLATE = 'This is not a template'
# Temporary element for marker atoms because 'H' is not sticky (tends to get
# added/deleted when converting structures)
TEMP_ELEMENT = 'Br'
PREF_GROUP = 'general_builder_widgets'
RESELECT_CURRENT_TEMPLATE = 'reselect current template'
RESET_TO_DEFAULT_CUSTOM_DIR = 'reset to default custom dir'
NONE_ITEM = 'None'
MARKING_INFO = ('To mark an atom, either right click on it and choose "Set '
                'R group" or hover over it and type "Rx", where x is the '
                'desired R group number.')
[docs]def get_builtin_template_items(path, item_type, item_class=None):
    """
    Get a list of the built-in templates
    :param str path: The path to the script (generated via __file__ property)
    :param str item_type: The type item these templates are. Will form the base
        name of the template directory. For example, polymer items might have
        types of "monomer" or "initiator" while complexes might have have a
        type of "ligand".
    :param type(QtWidgets.QListWidgetItem) item_class: The base class to use
        when creating the template items. Defaults to QtWidgets.QListWidgetItem
    :return tuple(item_class): A list of the builtin template items
    """
    if item_class is None:
        item_class = QtWidgets.QListWidgetItem
    built_in_path = get_builtin_directory(path=path, item_type=item_type)
    items = [item_class(CUSTOM_TEMPLATE)]
    for file_path, _ in fileutils.get_files_from_folder(built_in_path):
        if fileutils.get_file_extension(file_path) in TEMPLATE_EXTENSIONS:
            template_name = Path(file_path).stem.replace('_', ' ')
            item = item_class(template_name)
            items.append(item)
    return tuple(items) 
[docs]def get_builtin_directory(path, item_type):
    """
    Given a path to a script and an item type, generate the path to built-in
    templates for that script and item_type
    If the script is located at /A/B/bob.py, the path returned will be
    /A/B/bob_dir/item_type_templates
    :type path: str
    :param path: The path to the script (generated via __file__ property)
    :type item_type: str
    :param item_type: The type item these templates are. Will form the base name
        of the template directory
    :rtype: str
    :return: The path to the built-in directory for templates of item_type for
        the script at path
    """
    script_dir, script_name = os.path.split(os.path.abspath(path))
    builtin_base_dir = os.path.join(script_dir,
                                    os.path.splitext(script_name)[0] + '_dir')
    built_in_path = os.path.join(builtin_base_dir, item_type + '_templates')
    return built_in_path 
# The following constants and functions were moved out this module so they could
# be used in processes that can't import modules requiring a display. They are
# re-defined here to preserve this module's API so that any uses in the wild are
# preserved.
HIGHEST_RX_MARKER_XVAL = buildcomplex.HIGHEST_RX_MARKER_XVAL
ATOM_MARKER_PROP_BASE = buildcomplex.ATOM_MARKER_PROP_BASE
ETA_ATOMS_PROP = buildcomplex.ETA_ATOMS_PROP
get_marker_atom_indexes_from_structure = \
        buildcomplex.get_marker_atom_indexes_from_structure
mark_eta_positions = buildcomplex.mark_eta_positions
get_eta_marker_indexes = buildcomplex.get_eta_marker_indexes
clear_marker_properties = buildcomplex.clear_marker_properties
set_marker_properties = buildcomplex.set_marker_properties
[docs]class TemplateSelector(swidgets.SelectorWithPopUp):
    """
    A frame that allows the user to specify a template structure from a pop up
    list.
    """
    # Should be a child class of TemplateSelectorFilterListToolButton
    TOOL_BUTTON_CLASS = None
[docs]    def __init__(self, *args, item_class=None, command=None, **kwargs):
        """
        Extend super's __init__ by assigning a self.item_class attribute. This
        attribute is the base class used when creating/updating the item list
        in this class' list widget.
        :param type(QtWidgets.QListWidgetItem) item_class: The child class of
            QListWidgetItem to use when updating or resetting items in the list
            widget. If `None`, then defaults to the base QListWidgetItem class.
        :param function command: function to call when the selector's tool
            button's pop up's data are changed
        """
        if item_class is None:
            item_class = QtWidgets.QListWidgetItem
        self.item_class = item_class
        super().__init__(*args, **kwargs)
        if command:
            self.tool_btn._pop_up.dataChanged.connect(command) 
[docs]    def selectionChanged(self):
        """
        Set the line edit to the newly selected template name
        """
        self.selection_le.setText(str(self.tool_btn.getSelection())) 
[docs]    def getSelection(self):
        return self.tool_btn.getSelection() 
[docs]    def setSelection(self, template):
        """
        Set the template name for the widget
        :param str template: The name of the template structure to set
        :raise ValueError: If the template name is not recognized
        """
        self.tool_btn.setSelection(template)
        self.selection_le.setText(self.tool_btn.getSelection()) 
[docs]    def reset(self):
        """
        Reset the widget
        """
        self.setSelection(self.default_selection) 
    @property
    def _list_widget(self):
        """
        The list widget of the pop up attached to the tool button attached to
        this selector
        :rtype TemplateSelectorFilterListPopUp:
        """
        return self.tool_btn._pop_up._list_widget
[docs]    def silentlySetTemplates(self, template_names):
        """
        Set the items for the selector without emitting any signals
        :param list(str) template_names: A list of the new template names for
            the selector
        """
        with qtutils.suppress_signals(self._list_widget):
            self._list_widget.clear()
            for name in template_names:
                item = self.item_class(name)
                self._list_widget.addItem(item) 
[docs]    def silentlyTryToSelectTemplate(self, text):
        """
        Select the item with the given text and do so without emitting any
        signals. Also do not give an error if no item with text exists.
        :param str text: The text of the item to select
        """
        with qtutils.suppress_signals(self, self._list_widget):
            try:
                self.setSelection(text)
            except ValueError:
                pass  
[docs]class TemplateCombo(swidgets.SComboBox):
    """
    A Combo Box for managing template lists
    """
[docs]    def __init__(self, *args, **kwargs):
        """
        See parent class for documentation
        """
        maxwidth = kwargs.pop('max_width', 450)
        swidgets.SComboBox.__init__(self, *args, **kwargs)
        self.setSizeAdjustPolicy(self.AdjustToContents)
        self.setMaximumWidth(maxwidth) 
[docs]    def silentlySetItems(self, items):
        """
        Set the items for the selector without emitting any signals
        :type items: list
        :param items: The new items (str) for the combo box
        """
        with qtutils.suppress_signals(self):
            self.clear()
            self.addItems(items) 
[docs]    def silentlyTryToSelectText(self, text):
        """
        Select the item with the given text and do so without emitting any
        signals. Also do not give an error if no item with text exists.
        :type text: str
        :param text: The text of the item to select
        """
        with qtutils.suppress_signals(self):
            try:
                self.setCurrentText(text)
            except ValueError:
                pass  
[docs]class TemplateManager(QtWidgets.QDialog):
    """
    A dialog that manages the user templates - currently only allows deletion
    """
    customDirChanged = QtCore.pyqtSignal(str)
[docs]    def __init__(self, parent, templates, custom_path):
        """
        Create a TemplateManager object
        :type parent: QWidget
        :param parent: The window to display this dialog over
        :type templates: list
        :param templates: list of template names in the self.parent.custom_path
            directory
        :type custom_path: str
        :param custom_path: The directory where the custom templates are stored
        """
        QtWidgets.QDialog.__init__(self, parent)
        self.setWindowTitle('Manage Templates')
        layout = swidgets.SVBoxLayout(self)
        layout.setContentsMargins(6, 6, 6, 6)
        # Template deletion
        dlayout = swidgets.SHBoxLayout(layout=layout)
        self.select_combo = swidgets.SLabeledComboBox('Template:',
                                                      items=templates,
                                                      layout=dlayout)
        swidgets.SPushButton('Delete Template',
                             command=self.deleteTemplate,
                             layout=dlayout)
        dlayout.addStretch()
        # Custom template directory setting
        clayout = swidgets.SHBoxLayout(layout=layout)
        swidgets.SPushButton('Change Custom Template Directory...',
                             command=self.changeTemplateDirectory,
                             layout=clayout)
        swidgets.SPushButton('Use Default Directory',
                             command=self.useDefaultTemplateDirectory,
                             layout=clayout)
        self.custom_path = custom_path 
[docs]    def deleteTemplate(self):
        """
        Delete the selected template
        """
        template_basename = str(self.select_combo.currentText())
        if not template_basename:
            return
        filename = os.path.join(self.custom_path,
                                template_basename + TEMPLATE_EXTENSION)
        fileutils.force_remove(filename)
        self.select_combo.removeItem(self.select_combo.currentIndex()) 
[docs]    def changeTemplateDirectory(self):
        """
        Change the directory the custom templates are read from/saved to
        """
        path = filedialog.get_existing_directory(dir=self.custom_path)
        if not path:
            return
        self.customDirChanged.emit(path)
        # Close because our data is no longer valid
        self.accept() 
[docs]    def useDefaultTemplateDirectory(self):
        """
        Switch the custom template directory back to the default directory
        """
        self.customDirChanged.emit(RESET_TO_DEFAULT_CUSTOM_DIR)
        # Close because our data is no longer valid
        self.accept()  
[docs]class SketcherStructureMixin(object):
    """
    Contains some general use methods for getting a ligand structure from the 2D
    sketcher
    """
[docs]    def changeMarkerAtomsToTempElement(self, struct, rx_atoms=None):
        """
        Change all the marker atoms in the structure to a specific element
        :type struct: `schrodinger.structure.Structure`
        :param struct: The structure to modify
        :type rx_atoms: dict
        :param rx_atoms: A dictionary with keys that are the x value for Rx
            atoms and values that are lists of atom indexes with that x value.
            If not supplied, the Rx atoms will be found from structure
            properties.
        """
        if msutils.is_coarse_grain(struct):
            # Coarse grained structure causes RuntimeError: Could not clear
            # isotope when changing Atomic number in buildcomplex.transmute_atom
            return
        if not rx_atoms:
            rx_atoms, max_x = get_marker_atom_indexes_from_structure(struct)
        for indexes in rx_atoms.values():
            for index in indexes:
                buildcomplex.transmute_atom(struct.atom[index], TEMP_ELEMENT) 
[docs]    def getSketcherStructure(self, quiet_if_empty=False):
        """
        Get the structure from the sketcher and set it up for use as a Ligand
        Performs the following manipulations:
            - Stores the index of the R1/R2 marker atoms as structure properties
            - Changes R1/R2 to hydrogen atoms
            - Checks to ensure consistent use of Rx in the structure
        :type quiet_if_empty: bool
        :param quiet_if_empty: If True, do not post a warning if the sketcher
            contains no structure
        :rtype: `schrodinger.structure.Structure` or None
        :return: The structure object from the sketcher, or None if an error
            occured along the way
        """
        multistruct_msg = ('There must be one and only one molecule in the '
                           '2D Sketcher when setting the structure.')
        # Get the structure and Rx atoms
        rdmol = self.sketcher.getRDKitStructure()
        if not rdmol or not rdmol.GetNumAtoms():
            if not quiet_if_empty:
                self.warning(multistruct_msg)
            # None or more than 1 structure in the sketcher
            return None
        rx_atoms, max_x = self.getRxAtoms()
        # Validate that we have the correct number of Rx atoms
        valid = self.validateRAtomIdentity(rx_atoms, max_x)
        if not valid:
            return None
        # Check if all elements are supported (MATSCI-11496)
        if any(a.GetAtomicNum() > mm.MMELEMENTS_MAX for a in rdmol.GetAtoms()):
            self.warning('Elements with atomic numbers larger than '
                         f'{mm.MMELEMENTS_MAX} are not supported.')
            return
        # Regenerate valences and allow odd valence states (MATSCI-4524)
        rdmol.UpdatePropertyCache(strict=False)
        # Convert rdmol to a structure
        struct = rdkit_adapter.from_rdkit(rdmol)
        # Set dummy atoms' atomic numbers to 0 before calling fast3d
        # (Workaround until SHARED-7523 is implemented)
        for atom in struct.atom:
            if atom.atomic_number == -2:
                atom.atomic_number = 0
        try:
            struct.generate3dConformation(require_stereo=False)
        except RuntimeError as err:
            self.warning('Unable to generate 3D coordinates for ligand\n' +
                         str(err))
        if struct.mol_total != 1:
            self.warning(multistruct_msg)
            return
        set_marker_properties(struct, rx_atoms)
        # Change the atom to the Temp Element - it may not persist if it is a
        # hydrogen
        self.changeMarkerAtomsToTempElement(struct, rx_atoms=rx_atoms)
        # Validate Rx atoms based on the whole structure
        valid = self.validateRAtomStructure(struct, rx_atoms, max_x)
        if not valid:
            return None
        return struct 
[docs]    def getRxAtoms(self):
        """
        Find the atoms marked as R groups in the sketcher structure
        :rtype: (dict, int)
        :return: dict keys are the int value of x in Rx, values are lists of
            atom indexes set to that Rx value (atom indexes are 1-based). The int
            return value is the largest value of x in the dictionary keys.
        """
        # Figure out which atoms are marked as R-Groups
        rgroups = self.sketcher.listOfAtomRGroups()
        max_x = 0
        rx_atoms = {}
        # Sketcher atoms are indexed starting at 0
        for index, rval in enumerate(rgroups, 1):
            if rval > 0:
                rx_atoms.setdefault(rval, []).append(index)
                if rval > max_x:
                    max_x = rval
        return rx_atoms, max_x 
[docs]    def validateRAtomIdentity(self, rx_atoms, max_x):
        """
        Overwrite in a child class to run validation on the values of the R
        atoms and error code. An error message should be displayed to the user
        by this method if appropriate
        :type rx_atoms: dict
        :param rx_atoms: keys are the int value of x in Rx, values are lists of
            atom indexes set to that Rx value (atom indexes are 1-based)
        :type max_x: int
        :param max_x: The larget value of x in the keys of rx_atoms
        :rtype: bool
        :return: True if everything is OK, False if not
        """
        return True 
[docs]    def validateRAtomStructure(self, struct, rx_atoms, max_x):
        """
        Overwrite in a child class to run validation on the R atoms that
        requires a `schrodinger.structure.Structure` object. An error message
        should be displayed to the user by this method if appropriate.
        :type struct: `schrodinger.structure.Structure`
        :param struct: The structure to use for validating the Ra atoms
        :type rx_atoms: dict
        :param rx_atoms: keys are the int value of x in Rx, values are lists of
            atom indexes set to that Rx value (atom indexes are 1-based)
        :type max_x: int
        :param max_x: The larget value of x in the keys of rx_atoms
        :rtype: bool
        :return: True if everything is OK, False if not
        """
        return True  
[docs]class MinimizeMixin(object):
    """
    Methods to convert a 2D structure to a 3D structure and minimize it
    """
[docs]    def convert2DTo3DAndMinimize(self):
        """
        Minimize the structure, converting from 2D to 3D first if necessary
        """
        if not self.structure.has3dCoords():
            # Convert to 3D
            self.structure.generate3dConformation(require_stereo=False)
        # We use OPLS 2005 to avoid errors with QM charges for our
        # modified ligand structures. (MATSCI-4008). We call with
        # cleanup = False to avoid mmlewis issues with the marker atoms
        # - some of which should be zero-bonded and some single-bonded
        # in order to get the valence totally correct. MATSCI-4524
        try:
            with minimize.minimizer_context(ffld_version=minimize.OPLS_2005,
                                            cleanup=False,
                                            struct=self.structure) as mizer:
                self.modifyMinimizer(mizer)
                mizer.minimize()
        except Exception as err:
            msg = str(err) + '\n\nUsing unminimized ligand structure'
            if "mmffld_minimize_lic" in str(err):
                self.master.warning('A license error occurred:\n' + msg)
            else:
                self.master.warning('An error occurred during '
                                    '3D structure minimization: ' + msg) 
[docs]    def findAttachmentMarkers(self):
        """
        Find the atoms that mark attachment points.  The index of each marker
        atom is stored in self.markers in ascending order of Rx value.
        """
        marker_dict, max_x = get_marker_atom_indexes_from_structure(
            self.structure)
        # Find the attachment points
        self.markers = []
        for xval in range(1, max_x + 1):
            self.markers.extend(marker_dict.get(xval, []))
        self.eta_indexes = get_eta_marker_indexes(self.structure)  
[docs]class SketcherBox(SketcherStructureMixin, swidgets.SFrame):
    """
    Set of widgets that controls a 2D sketcher and has additional widgets that
    allow the user to load/save/delete templates for the sketcher and import
    structures from the workspace into the sketcher.
    """
    templatesUpdated = QtCore.pyqtSignal((list, str))
[docs]    def __init__(self,
                 master,
                 builtin_path,
                 custom_dirname,
                 layout,
                 add_custom_template=True,
                 single_rx_atom=False):
        """
        Create a SketcherBox instance
        :type master: QWidget
        :param master: Must have a warning method
        :type builtin_path: str
        :param builtin_path: The absolute path to the built-in templates
        :type custom_dirname: str
        :param custom_dirname: The base name of the path to the custom templates
        :type layout: QBoxLayout
        :param layout: The layout to place this widget into
        :type add_custom_template: bool
        :param add_custom_template: Whether "Custom" template should be allowed
            in template list
        :param single_rx_atom: If True, there can only be one single rx atom
        :type single_rx_atom: bool
        """
        self.master = master
        self.add_custom_template = add_custom_template
        self.single_rx_atom = single_rx_atom
        self.warning = master.warning
        self.single_rx_templates = {}
        swidgets.SFrame.__init__(self,
                                 layout_type=swidgets.VERTICAL,
                                 layout=layout)
        # Preferences
        self.prefs = preferences.Preferences(preferences.SCRIPTS)
        self.prefs.beginGroup(PREF_GROUP)
        self.custom_dir_basename = custom_dirname
        # Template widgets
        self.template_frame = swidgets.SFrame(layout=self.mylayout,
                                              layout_type=swidgets.HORIZONTAL)
        tlayout = self.template_frame.mylayout
        self.leftmost_label = swidgets.SLabel('Template:', layout=tlayout)
        self.template_combo = TemplateCombo(layout=tlayout)
        self.template_combo.currentIndexChanged.connect(self.loadTemplate)
        self.save_template_btn = swidgets.SPushButton('Save New Template...',
                                                      command=self.saveTemplate,
                                                      layout=tlayout)
        self.manage_template_btn = swidgets.SPushButton(
            'Manage Templates...', command=self.manageTemplates, layout=tlayout)
        tlayout.addStretch()
        # Fill the template combo
        self.builtin_path = builtin_path
        default_custom_dir = self.getDefaultCustomTemplateDir()
        self.setCustomTemplateDir(
            self.prefs.get(custom_dirname, default=default_custom_dir))
        try:
            os.makedirs(self.custom_path)
        except OSError:
            # Directory already exists (or we can't create it)
            pass
        self.templates = []
        self.builtin_templates = set()
        self.buildTemplateList()
        # Other custom sketcher controls
        clayout = swidgets.SHBoxLayout(layout=self.mylayout)
        if maestro:
            self.workspace_btn = swidgets.SPushButton(
                'Import From Workspace',
                command=self.importWorkspace,
                layout=clayout)
        clayout.addStretch()
        self.clear_btn = swidgets.SPushButton('Clear Sketcher',
                                              command=self.clearSketcher,
                                              layout=clayout)
        # Sketcher
        self.sketcher = sketcher.sketcher()
        self.mylayout.addWidget(self.sketcher)
        # Tooltips
        tip = 'Load a template into the Sketcher'
        self.template_combo.setToolTip(tip)
        tip = 'Save a new template for use in future sessions.'
        self.save_template_btn.setToolTip(tip)
        tip = 'Delete one or more custom templates'
        self.manage_template_btn.setToolTip(tip)
        tip = 'Clear the Sketcher of all structures'
        self.clear_btn.setToolTip(tip) 
[docs]    def filterTemplates(self):
        """
        Filter templates based on whether single rx atom is requested.
        :return: valid templates
        :rtype: list
        """
        if not self.single_rx_atom and self.template_combo.count() == len(
                self.templates):
            return
        templates = self.templates[:]
        if self.single_rx_atom:
            for template in self.templates:
                if template == CUSTOM_TEMPLATE:
                    self.single_rx_templates[template] = True
                    continue
                if template in self.single_rx_templates:
                    continue
                struct = self.readTemplateStructure(template)
                midxs, _ = get_marker_atom_indexes_from_structure(struct)
                self.single_rx_templates[template] = sum(
                    [len(x) for x in midxs.values()]) == 1
            templates = [x for x, y in self.single_rx_templates.items() if y]
        self.template_combo.silentlySetItems(templates)
        return templates 
[docs]    def getDefaultCustomTemplateDir(self):
        """
        Get the default directory for user templates
        """
        return os.path.join(msutils.get_matsci_user_data_dir(),
                            self.custom_dir_basename) 
[docs]    def setCustomTemplateDir(self, path):
        """
        The user changed the custom template directory
        :type path: str
        :param path: The new custom directory path. Use the module constant
            RESET_TO_DEFAULT_CUSTOM_DIR to reset to the Schrodinger default
        """
        if path == RESET_TO_DEFAULT_CUSTOM_DIR:
            path = self.getDefaultCustomTemplateDir()
        self.custom_path = path
        self.prefs.set(self.custom_dir_basename, path) 
[docs]    def getRecentTemplatePath(self):
        """
        Get the path to the recent template file
        :rtype: str
        :return: The path to the recent template file
        """
        return os.path.join(self.custom_path, RECENT_TEMPLATE_FILE) 
[docs]    def importWorkspace(self):
        """
        Get the workspace structure and place it in the sketcher
        """
        struct = maestro.workspace_get()
        if msutils.is_coarse_grain(struct, by_atom=True):
            self.warning('Coarse-grained systems cannot be used as ligands')
            return
        # Check for dummy atoms - a single dummy atom indicates a ligand binding
        # point. A dummy atom bound to another dummy is just a dummy - probably
        # from the Maestro method of representing eta-ligands (dummy out of
        # plane attached to a dummy in-plane at the center of the eta atoms)
        rxinfo = []
        rxval = 1
        delatoms = []
        dummy = 'DU'
        for atom in struct.atom:
            if atom.element.upper() == dummy:
                if (atom.bond_total == 1 and
                        next(atom.bonded_atoms).element.upper() == dummy):
                    delatoms = [atom.index]
                else:
                    rxinfo.append((rxval, atom))
                    rxval += 1
        # Below call is harmless if there are no atoms to delete
        struct.deleteAtoms(delatoms)
        # Create the rx atom dict after deleting atoms to ensure the atom
        # indexes are correct
        rx_atoms = {x: [atom.index] for x, atom in rxinfo}
        self.setSketcherStructure(struct)
        if rx_atoms:
            self.setAtomRNumbers(struct, rx_atoms) 
[docs]    def clearSketcher(self):
        """
        Remove the current structure from the sketcher
        """
        self.sketcher.clearStructure()
        self.template_combo.setCurrentIndex(0) 
[docs]    def buildTemplateList(self, select=""):
        """
        Build a list of all templates - built in or user-defined.  This list is
        stored in self.templates and replaces the current list in the Template
        Combobox.
        :type select: str
        :param select: The template that should be selected in the Template
            Combobox when all is said and done.
        """
        # Preserve the original select setting so that we can pass it on to the
        # item row for use with its template combo.
        original_select = select
        if select == RESELECT_CURRENT_TEMPLATE:
            select = str(self.template_combo.currentText())
        # First get the built-in templates
        self.builtin_templates = set()
        fileglob = os.path.join(self.builtin_path, TEMPLATE_GLOB)
        for filename in glob.iglob(fileglob):
            name = os.path.splitext(os.path.split(filename)[1])[0]
            self.builtin_templates.add(name)
        # Next get the user templates
        template_set = self.builtin_templates.copy()
        user_templates = self.getUserTemplateList()
        template_set.update(user_templates)
        # Now sort by recently used (alphabetical being the default)
        self.templates = list(template_set)
        self.templates.sort()
        recent = self.getRecentTemplatesList()
        def sortkey(name):
            try:
                return recent.index(name)
            except ValueError:
                if name in self.builtin_templates:
                    # Place built-in templates after user templates
                    return 10000
                else:
                    return 1000
        self.templates.sort(key=sortkey)
        if self.add_custom_template:
            self.templates.insert(0, CUSTOM_TEMPLATE)
        self.template_combo.silentlySetItems(self.templates)
        self.template_combo.silentlyTryToSelectText(select)
        self.templatesUpdated.emit(self.templates, original_select) 
[docs]    def getUserTemplateList(self):
        """
        Get the list of user templates
        :rtype: list
        :return: list of template names in the self.custom_path
        """
        fileglob = os.path.join(self.custom_path, TEMPLATE_GLOB)
        template_names = []
        for filename in glob.iglob(fileglob):
            name = os.path.splitext(os.path.split(filename)[1])[0]
            template_names.append(name)
        return template_names 
[docs]    def getRecentTemplatesList(self):
        """
        Get a list of recently used templates.
        :rtype: list of str
        :return: Items of the list are the names of recently used templates.
            The list is sorted in order from most to least recently used.
        """
        try:
            recent_file = open(self.getRecentTemplatePath())
            used_list = [x.strip() for x in recent_file if x.strip()]
            recent_file.close()
        except IOError:
            used_list = []
        return used_list 
[docs]    def updateRecentTemplateFile(self, name):
        """
        Add a template as the most recently used template file.
        :type name: str
        :param name: The name of the most recently used template - should
            correspond to the name of the template structure file.
        """
        recent_list = self.getRecentTemplatesList()
        try:
            recent_list.remove(name)
        except ValueError:
            pass
        recent_list.insert(0, name)
        try:
            recent_file = open(self.getRecentTemplatePath(), 'w')
        except IOError:
            return
        recent_str = '\n'.join(recent_list)
        recent_file.write(recent_str)
        recent_file.close() 
[docs]    def saveTemplate(self):
        """
        Save the current sketcher structure as a new user-defined template
        """
        struct = self.getSketcherStructure()
        if not struct:
            return
        # Get the template name
        template_name = TemplateNameDialog.getText(self)
        if template_name:
            if not os.path.exists(self.custom_path):
                try:
                    os.makedirs(self.custom_path)
                except OSError:
                    self.warning('Unable to create directory %s for user'
                                 ' template files.' % self.custom_path)
                    return
            struct.title = template_name
            filename = template_name + TEMPLATE_EXTENSION
            path = os.path.join(self.custom_path, filename)
            try:
                struct.write(path)
            except IOError:
                self.warning('Could not write the template file\n%s' % path)
                return
            self.updateRecentTemplateFile(template_name)
            self.buildTemplateList(select=template_name)
            self.filterTemplates() 
[docs]    def readTemplateStructure(self, name):
        """
        Read in a template file based name. The file name read is name +
        TEMPLATE_EXTENSION.
        :type name: str
        :param name: The base name of the template file without the extension
        :rtype: `schrodinger.structure.Structure`
        :return: The structure that was read in
        """
        filename = name + TEMPLATE_EXTENSION
        if name in self.builtin_templates:
            directory = self.builtin_path
        else:
            directory = self.custom_path
        path = os.path.join(directory, filename)
        struct = structure.Structure.read(path)
        # Convert the R-Group marker atoms to the TEMP_ELEMENT - even though
        # they should be already, but files from early versions use 'F' which
        # makes the marker labels an ugly green.
        self.changeMarkerAtomsToTempElement(struct)
        return struct 
[docs]    def loadTemplate(self, template_index):
        """
        Load a template into the sketcher
        :type template_index: int
        :param template_index: The index in the templates list of the template
            to load
        """
        if not template_index:
            # Index = 0 is the Custom template - i.e. user draws the structure
            return
        # Find the file and load the structure
        template_name = self.templates[template_index]
        struct = self.readTemplateStructure(template_name)
        # Set the sketcher structure
        self.setSketcherStructure(struct)
        # Update the Template list
        self.updateRecentTemplateFile(template_name)
        self.buildTemplateList(select=template_name)
        # Find which atoms should be marked with Rx designations
        rx_atoms, max_x = get_marker_atom_indexes_from_structure(struct)
        if not rx_atoms:
            self.warning('No Rx-labeled coordination sites - not a valid '
                         'template file.')
            return
        self.setAtomRNumbers(struct, rx_atoms) 
[docs]    def setAtomRNumbers(self, struct, rx_atoms):
        """
        Set the Rnumbers based on the structure with Hydrogen atoms and the
        corresponding atom indexes marked with that Rx value.
        :type struct: structure.Structure
        :param struct: a monomer structure
        :type rx_atoms: dict
        :param rx_atoms: keys are Rx x values and values are lists of atom
            indexes marked with that Rx value
        """
        # Hydrogens are not counted in the atom count for sketcher atoms, so
        # we have to reduce Rx indexes by the number of hydrogens that
        # come before them.
        hcounts = {}
        num_h = 0
        for atom in struct.atom:
            hcounts[atom.index] = num_h
            if atom.element == 'H':
                num_h = num_h + 1
        rdkit_mol = self.sketcher.getRDKitStructure()
        for xval, indexes in rx_atoms.items():
            for index in indexes:
                index_without_h = index - hcounts[index]
                # Sketcher atoms are 0-indexed, so subtract another 1
                rdkit_atom = rdkit_mol.GetAtomWithIdx(index_without_h - 1)
                rgroup.change_to_rgroup(rdkit_atom=rdkit_atom,
                                        rgroup_number=xval)
        self.sketcher.setStructure(rdkit_mol, self.getSketcherImportSettings()) 
[docs]    def getSketcherImportSettings(self):
        """
        Returns sketcher import structure settings.
        :rtype: sketcher.sketcherImportStructureSettings
        :return: sketcher import structure settings for panel usage
        """
        settings = sketcher.sketcherImportStructureSettings()
        settings.clearStructure = True
        settings.coordinates = sketcher.COORDINATE_GENERATION.REGENERATE
        settings.sync = False
        settings.deleteExplicitHs = True
        return settings 
[docs]    def setSketcherStructure(self, struct, rm_polymer_prop=True):
        """
        Set struct as the structure in the sketcher
        :type struct: structure.Structure
        :param struct: the structure to be placed in the 2D sketcher
        :type rm_polymer_prop: bool
        :param rm_polymer_prop: remove the s_matsci_polymer_role property
            for each atom, if True
        """
        if rm_polymer_prop:
            msutils.remove_atom_property(struct, mm.M2IO_DATA_ATOM_POLYMER_ROLE)
        # Copied from MM_UIMSketcher::setStructure
        rdmol = rdkit_adapter.to_rdkit(struct, sanitize=False)
        self.sketcher.setStructure(rdmol, self.getSketcherImportSettings()) 
[docs]    def manageTemplates(self):
        """
        Open a window to allow the user to manage existing templates
        """
        user_templates = self.getUserTemplateList()
        manager = TemplateManager(self, user_templates, self.custom_path)
        manager.customDirChanged.connect(self.setCustomTemplateDir)
        manager.exec_()
        self.buildTemplateList(select=RESELECT_CURRENT_TEMPLATE)
        self.filterTemplates() 
[docs]    def reset(self):
        """
        Reset all widgets
        """
        self.sketcher.clearStructure()
        self.template_combo.reset()
        self.buildTemplateList()  
[docs]class ItemRow(MinimizeMixin, swidgets.SFrame):
    """
    A row of control widgets for a ligand
    """
[docs]    def __init__(self, master, row_layout, item_type, unset_tip):
        """
        Create a ItemRow instance
        :type master: `schrodinger.ui.qt.appframework.AppFramework`
        :param master: The parent panel for this row. Must have the following
            methods: deleteRow, setWaitCursor, restoreCursor
        :type row_layout: QLayout
        :param row_layout: The layout this ItemRow should add itself to
        :type item_type: str
        :param item_type: A string describing the type of item this row is, such
            as "ligand" - this will be displayed to the user in labels and tooltips.
            Use the lowercase form of the word - it will be capitalized when
            necessary.
        :type unset_tip: str
        :param unset_tip: The tooltip for the label when no structure has been
            set.
        """
        swidgets.SFrame.__init__(self,
                                 layout=row_layout,
                                 layout_type=swidgets.HORIZONTAL)
        self.master = master
        # Leading label
        cap_item_type = item_type.capitalize()
        self.label = swidgets.SLabel(cap_item_type + ': ', layout=self.mylayout)
        self.label.setToolTip(
            'Each row controls the data for one %s structure' % item_type)
        # Status label
        self.status_label = StructureLabel(self, None, unset_tip)
        # Delete button
        self.delete_btn = swidgets.DeleteButton(command=self.delete)
        self.delete_btn.setMaximumWidth(30)
        self.delete_btn.setStyleSheet('QPushButton {color: red}')
        self.delete_btn.setToolTip('Delete this %s' % item_type)
        self.structure = None
        self.markers = []
        self.eta_indexes = set() 
[docs]    def reset(self):
        """
        Reset all widgets
        """
        self.label.reset()
        self.status_label.unset()
        self.structure = None
        self.markers = []
        self.eta_indexes = set() 
[docs]    def delete(self):
        """
        Delete this row
        """
        self.master.deleteRow(self)
        self.close() 
[docs]    def setWaitCursor(self):
        """
        Change to a wait cursor - needed for the wait_cursor decorator
        """
        self.master.setWaitCursor() 
[docs]    def restoreCursor(self):
        """
        Change to a wait cursor - needed for the wait_cursor decorator
        """
        self.master.restoreCursor() 
[docs]    def postTreatMinimizedStructure(self, marker_element=TEMP_ELEMENT):
        """
        Perform any necessary actions on a just-minimized structure, perhaps
        undoing some of the prep done by prepareStructureForMinimization. The
        default changes the marker atoms back to TEMP_ELEMENT atoms.
        :type marker_element: str
        :param marker_element: The element to change the marker atom to (the
            atom that marks the attachment point and is discarded when the
            structure is added to a larger structure)
        """
        for index in self.markers:
            buildcomplex.transmute_atom(self.structure.atom[index],
                                        marker_element) 
[docs]    def modifyMinimizer(self, mizer):
        """
        This method allows subclasses to customize the Minimizer in any way they
        see fit. The structure has already been set for the Minimizer and is a
        temporary copy of self.structure.
        :type mizer: schrodinger.structutils.minimizer.Minimizer
        :param mizer: The Minimizer object that will minimize the structure.
        """ 
[docs]    def getStructure(self):
        """
        Get the structure that has been requested
        """
        self.structure = self.master.getSketcherStructure() 
[docs]    def removeFFProperties(self):
        """
        Remove any properties set on structure from force field minimization so
        that those properties don't propagate to any structure built from this
        structure.
        """
        for prop in list(self.structure.property):
            if prop[1:].startswith('_ff_'):
                del self.structure.property[prop] 
[docs]    @decorators.wait_cursor
    def setStructure(self):
        """
        Set the structure for this row.  Including:
        - Find the marker atoms
        - optimize ligand structure for best complex binding
        """
        self.status_label.unset()
        self.structure = None
        self.markers = []
        self.eta_indexes = set()
        self.getStructure()
        if self.structure is None:
            return
        # Check for properly defined attachments
        self.findAttachmentMarkers()
        if not self.markers:
            return
        # Coarse grained doesn't support minimization
        if not msutils.is_coarse_grain(self.structure):
            # Do this before adding hydrogens because it converts marker atoms to H
            # or Dummies so we don't add H to them.
            self.prepareStructureForMinimization()
            # The sketcher structure doesn't include hydrogen atoms by default
            self.addHydrogens()
            self.convert2DTo3DAndMinimize()
            self.removeFFProperties()
            self.postTreatMinimizedStructure()
        self.fillStatusLabel() 
[docs]    def fillStatusLabel(self):
        """
        Set the status label using the new structure
        """
        self.status_label.set(self.structure) 
[docs]    def addHydrogens(self):
        """
        Add hydrogens to the structure
        """
        build.add_hydrogens(self.structure)  
[docs]class StructureLabel(swidgets.SLabel):
    """
    A label that gives information about the ligand for a given row and shows
    the set structure in a tooltip.
    """
[docs]    def __init__(self,
                 master,
                 layout,
                 unset_tooltip,
                 unset_text="Not set",
                 prefix="",
                 suffix=""):
        """
        Create a StructureLabel instance
        :type master: `ItemRow`
        :param master: The ItemRow object this label is for
        :type layout: QBoxLayout
        :param layout: The layout this label should add itself to
        :type unset_tooltip: str
        :param unset_tooltip: The tooltip to display if no structure has been
            set.
        :type unset_text: str
        :param unset_text: The text to display in the label if no structure has
            been set
        :type prefix: str
        :param prefix: The text to put before the molecular formula
        :type suffix: str
        :param suffix: The text to put after the molecular formula
        """
        # Must set tooltip attribute at the very beginning, as it is used in the
        # event method, which gets called during some SLabel __init__ step.
        # MATSCI-1495
        self.tooltip = None
        swidgets.SLabel.__init__(self, unset_text, layout=layout)
        self.master = master
        self.unset_text = unset_text
        self.unset_tip_text = unset_tooltip
        self.default_prefix = prefix
        self.default_suffix = suffix
        self.unset() 
[docs]    def unset(self):
        """
        Change the text & tooltip to indicate there is no ligand set
        """
        self.tooltip = None
        self.setText(self.unset_text)
        self.setToolTip(self.unset_tip_text) 
[docs]    def set(self, struct, prefix=None, suffix=None):
        """
        Change the text & tooltip to indicate there is a ligand set
        :type struct: `schrodinger.structure.Structure`
        :param struct: The structure that is set for this row
        :type prefix: str
        :param prefix: A string to add before the molecular formala - if not
            given, the default prefix set in the __init__ method is used.
        :type suffix: str
        :param suffix: A string to add after the molecular formala - if not
            given, the default suffix set in the __init__ method is used.
        """
        if prefix is None:
            prefix = self.default_prefix
        if suffix is None:
            suffix = self.default_suffix
        if suffix:
            suffix = ' ' + suffix
        if msutils.is_coarse_grain(struct):
            formula = coarsegrain.get_atom_name_formula(
                struct, excluded_atom_ids=self.master.markers)
        else:
            formula = self.master.getMolecularFormula()
        self.setText('%s%s%s' % (prefix, formula, suffix))
        self.tooltip = RGroupStructureToolTip(struct) 
[docs]    def event(self, event):
        """
        Override event to make the structure tooltip work
        :type event: QEvent
        :param event: The QEvent object generated by this event
        :rtype: bool
        :return: Whether the event was recognized
        """
        if self.tooltip:
            if event.type() == event.ToolTip:
                self.tooltip.show()
                event.accept()
                return True
        return swidgets.SLabel.event(self, event) 
[docs]    def leaveEvent(self, event):
        """
        Removes the structure tooltip if necessary when the mouse leaves the
        widget.
        :type event: QEvent
        :param event: The QEvent object generated by this event
        """
        try:
            self.tooltip.finish()
        except AttributeError:
            swidgets.SLabel.leaveEvent(self, event)  
[docs]class SketchDialog(QtWidgets.QDialog):
    """
    A Dialog window that opens a SketcherBox instance
    """
[docs]    def __init__(self, master, sketcher, title):
        """
        Create a SketchDialog instance
        :type master: `EndGroupRow`
        :param master: The EndGroupRow object this dialog belongs to
        :type sketcher:
                `schrodinger.application.matsci.builderwidgets.SketcherBox`
        :param sketcher: The SketcherBox to display
        :type title: str
        :param title: The window title
        """
        self.master = master
        QtWidgets.QDialog.__init__(self, master)
        self.setWindowTitle(title)
        layout = swidgets.SVBoxLayout(self)
        layout.setContentsMargins(6, 6, 6, 6)
        layout.addWidget(sketcher)
        dbb = QtWidgets.QDialogButtonBox
        self.buttons = dbb(dbb.Cancel)
        self.buttons.addButton('Use This Structure', dbb.ApplyRole)
        self.buttons.clicked.connect(self.useStructure)
        self.buttons.rejected.connect(self.reject)
        layout.addWidget(self.buttons)
        size = self.size()
        size.setHeight(700)
        size.setWidth(700)
        self.resize(size) 
[docs]    def useStructure(self, button):
        """
        Prompt the EndGroupRow to use the currently sketched structure,
        and close the dialog if the structure is acceptable
        """
        if self.buttons.buttonRole(button) != self.buttons.ApplyRole:
            return
        success = self.master.useCustomTemplate()
        if success:
            return self.accept()
        return None  
[docs]class TMLigandRowMixin(object):
    """
    A mixin class that takes care of minimizing bidentate transition metal
    complex ligand structures so that they remain planar through the R1-...-R2
    bond path.
    Should be used with ItemRow classes. Example use:
        class LigandRow(TMLigandRowMixin, ItemRow):
            ...
    """
[docs]    def findAttachmentMarkers(self, set_dentation=True):
        """
        Find the atoms that mark attachment points and determine the
        mono/bi-dentation of the ligand.  The index of each marker atom is
        stored in self.markers.
        :type set_dentation: bool
        :param set_dentation: Whether to set the dentation_type property based
            on the current number of marker atoms of the structure
        """
        # Call the parent class method first.
        super(TMLigandRowMixin, self).findAttachmentMarkers()
        if set_dentation:
            if len(self.markers) == 2:
                self.dentation_type = buildcomplex.BIDENTATE
            elif len(self.markers) == 1:
                self.dentation_type = buildcomplex.MONODENTATE 
[docs]    def addHydrogens(self):
        """
        Add hydrogens to the structure, properly accounting for phantom Rx bonds
        and incorrect formal charges caused by those bonds.
        """
        # Eta-coordination is denoted in the 2D sketcher by bonding an Rx
        # atom to multiple atoms. That Rx bond occupies a valence position as
        # far as the add_bond method is concerned, so we need to break all those
        # bonds before adding hydrogens and then add them back after
        eta_neighbors = {}
        for index in self.eta_indexes:
            neighbors = list(self.structure.atom[index].bonded_atoms)
            eta_neighbors[index] = neighbors
            for neighbor in neighbors:
                self.structure.deleteBond(index, neighbor.index)
                # For some reason, N atoms automatically get +1 charge in the
                # sketcher when R1 violates its normal valence, while other
                # elements do not
                if neighbor.element == 'N':
                    order = sum([x.order for x in neighbor.bond])
                    if order == 3:
                        neighbor.formal_charge = 0
        build.add_hydrogens(self.structure)
        for eta_index, neighbors in eta_neighbors.items():
            for neighbor in neighbors:
                self.structure.addBond(eta_index, neighbor.index, 1) 
[docs]    def modifyMinimizer(self, mizer):
        """
        Add torsion constraints to the minimizer to keep the coordination sphere
        planar and with torsions of 0.
        :type mizer: schrodinger.structutils.minimizer.Minimizer
        :param mizer: The Minimizer object that will minimize the structure.
        """
        struct = mizer.getStructure()
        large_force = 10000.
        zero_torsion = 0.0
        # Make sure eta-complexed atoms remain planar
        for index in self.eta_indexes:
            eta_atoms = set(struct.atom[index].bonded_atoms)
            for tor in analyze.torsion_iterator(struct, atoms=eta_atoms):
                mizer.addTorsionRestraint(tor[0], tor[1], tor[2], tor[3],
                                          large_force, zero_torsion)
        # Make sure bidentate coordination sphere remain planar
        if self.dentation_type == buildcomplex.BIDENTATE:
            index1 = self.markers[0]
            index2 = self.markers[1]
            if (index1 not in self.eta_indexes and
                    index2 not in self.eta_indexes):
                bond_path = analyze.find_shortest_bond_path(
                    struct, index1, index2)
                if len(bond_path) < 4:
                    # The ligand part of the coordination ring is too short
                    # for a torsion
                    return
                for start in range(0, len(bond_path) - 3):
                    inds = bond_path[start:start + 4]
                    mizer.addTorsionRestraint(inds[0], inds[1], inds[2],
                                              inds[3], large_force,
                                              zero_torsion) 
[docs]    def postTreatMinimizedStructure(self, marker_element=TEMP_ELEMENT):
        """
        Undo the modifications to the structure that we made before
        minimization.  This means we add back the second marker and point it at
        the first marker.  The location of the second marker actually doesn't
        really matter, but pointed at the first marker is as good a place as
        any.
        """
        # Turn the marker atoms back to the Temp Element
        for index in self.markers:
            buildcomplex.transmute_atom(self.structure.atom[index],
                                        marker_element)
        # Because eta marker position is determined by the centroid of the eta
        # atoms, relocate it now that the eta atoms have been minimized
        self.relocateEtaMarkers() 
[docs]    def relocateEtaMarkers(self):
        """
        Move each eta marker to the centroid of the atoms it marks
        """
        for index in self.eta_indexes:
            eta_atom = self.structure.atom[index]
            bonded = [x.index for x in eta_atom.bonded_atoms]
            eta_atom.xyz = transform.get_centroid(self.structure,
                                                  atom_list=bonded)[:3]  
[docs]class ItemRowWithTemplates(ItemRow):
    """
    An ItemRow that holds the data for one unit and includes a combo for
    choosing Template structures
    """
    dataChanged = QtCore.pyqtSignal()
[docs]    def __init__(self,
                 master,
                 row_layout,
                 item_type,
                 template_selector_class,
                 default_template,
                 stretch=True,
                 unset_tip=None,
                 builtin_dir=None,
                 sketcher=True,
                 sketcher_class=None,
                 name_field=True,
                 none_item=False,
                 single_rx_atom=False):
        """
        Create a EndGroupRow instance
        :type master: `schrodinger.ui.qt.appframework.AppFramework`
        :param master: The parent panel for this row. Must have the following
            methods: deleteRow, setWaitCursor, restoreCursor
        :type row_layout: QLayout
        :param row_layout: The layout this EndGroupRow should add itself to
        :type item_type: str
        :param item_type: A string describing the type of item this row is, such
            as "monomer" - this will be displayed to the user in labels and
            tooltips.  Use the lowercase form of the word - it will be capitalized
            when necessary.
        :type template_selector_class: type(TemplateSelector)
        :param template_selector_class: A fully-implemented subclass of
            TemplateSelector (the class, not an instance)
        :type stretch: bool
        :param stretch: Should a layout stretch be added after all widgets have
            been laid out
        :type default_template: str
        :param default_template: The default template to load for this row when
            created.
        :type unset_tip: str
        :param unset_tip: The tooltip to use when no structure has been set
        :type builtin_dir: str
        :param builtin_dir: The path to the directory where built-in templates
            are stored. By default, it will be the script's '_dir' directory + the
            template name
        :type sketcher: bool
        :param sketcher: Whether to use a button that opens a sketcher window
        :type sketcher_class: `SketcherBox`
        :param sketcher_class: The 2D sketcher class to use in the Sketcher
            dialog window
        :type name_field: bool
        :param name_field: True if a name field should be added, False if not
        :type none_item: bool
        :param none_item: Combox has a None item if True
        :param single_rx_atom: If True, there can only be one single rx atom
        :type single_rx_atom: bool
        """
        self.none_item = none_item
        self.single_rx_atom = single_rx_atom
        if unset_tip is None:
            unset_tip = ('Choose a structure from the templates')
        ItemRow.__init__(self, master, row_layout, item_type, unset_tip)
        self.mol_weight = 0.0
        self.num_atoms = 0
        self.template_dirname = item_type + '_templates'
        if builtin_dir is None:
            self.built_in_path = self.template_dirname
        else:
            self.built_in_path = builtin_dir
        self.item_type = item_type
        # We need a sketcher instance even if it isn't shown, because that is
        # what handles templates
        if not sketcher_class:
            self.sketcher_class = SketcherBox
        else:
            self.sketcher_class = sketcher_class
        self.createSketcher()
        if sketcher:
            self.sketch_btn = swidgets.SPushButton('Sketch...',
                                                   command=self.sketchStructure,
                                                   layout=self.mylayout)
        self.template_selector = template_selector_class(
            layout=self.mylayout,
            command=self.setStructure,
            default_selection=default_template)
        # Item name
        self.mylayout.addWidget(self.status_label)
        if name_field:
            self.name_edit = swidgets.SLabeledEdit('Name:',
                                                   stretch=False,
                                                   layout=self.mylayout)
            # Policy expands upon resizing the parent panel (MATSCI-11028)
            self.name_edit.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
                                         QtWidgets.QSizePolicy.Fixed)
        else:
            self.name_edit = None
        if stretch:
            self.mylayout.addStretch(1000)
        # Add the default template to the sketcher's list of templates
        self.default_template = default_template
        templates = self.sketcher.templates
        if self.default_template not in templates:
            templates.insert(0, self.default_template)
        self.updateTemplates(templates, self.default_template)
        self.sketcher.templatesUpdated.connect(self.updateTemplates) 
[docs]    def reset(self):
        """
        Reset all widgets
        """
        ItemRow.reset(self)
        if self.name_edit:
            self.name_edit.setEnabled(True)
            self.name_edit.reset()
        self.updateTemplates(self.sketcher.templates, self.default_template) 
[docs]    def setSingleRxAtom(self, single_rx_atom):
        """
        Set the single Rx atom mode.
        :param single_rx_atom: If True, there can only be one single rx atom
        :type single_rx_atom: bool
        """
        self.single_rx_atom = single_rx_atom
        self.sketcher.single_rx_atom = self.single_rx_atom
        self.filterTemplates() 
[docs]    def filterTemplates(self):
        """
        Filter templates based on whether single rx atom is requested.
        """
        templates = self.sketcher.filterTemplates()
        if templates:
            self.template_selector.silentlySetTemplates(templates) 
[docs]    def useCustomTemplate(self):
        """
        Set the row to use the Custom template option
        """
        self.template_selector.silentlyTryToSelectTemplate(CUSTOM_TEMPLATE)
        self.setStructure()
        return bool(self.structure) 
[docs]    def sketchStructure(self):
        """
        Open up a sketcher to allow the user to sketch a new structure
        """
        title = self.item_type.replace('_', ' ').title() + ' Sketcher'
        sketch_dialog = SketchDialog(self, self.sketcher, title)
        # Load the structure in the 2D sketcher
        if self.structure is not None:
            rx_atoms, max_x = get_marker_atom_indexes_from_structure(
                self.structure)
            self.sketcher.setSketcherStructure(self.structure)
            # Change label of from Temp_element to Rx number
            self.sketcher.setAtomRNumbers(self.structure, rx_atoms)
        sketch_dialog.exec_()
        # Create a new sketcher as the old one gets deleted by the Dialog
        self.createSketcher()
        # User may have saved a new template even if they canceled the dialog
        self.reloadTemplateList() 
[docs]    def reloadTemplateList(self, select=""):
        """
        Reload the template selector with current information. If the current
        selection is not in the currect list of templates, then fall back to
        the default.
        :type select: str
        :param select: The template to select after the list is re-populated.
            May be the constant RESELECT_CURRENT_TEMPLATE to pick whatever the
            current template is
        """
        self.sketcher.buildTemplateList(select=select)
        selection = str(self.template_selector.getSelection())
        if selection not in self.sketcher.templates:
            selection = self.default_template
        self.updateTemplates(self.sketcher.templates, selection)
        self.filterTemplates() 
[docs]    def getNumMarkers(self):
        """
        Get the number of Rx atoms in this row
        :rtype: int
        :return: The number of Rx atoms in this row
        """
        return len(self.markers) 
[docs]    def getAtomsAndWeight(self):
        """
        Count the number of atoms and atomic weight for this row
        :rtype: (int, float)
        :return: The number of atoms and total molecular weight for the
            structure of this row - does not include marker atoms
        """
        if not self.structure:
            return 0, 0.0
        scopy = self.structure.copy()
        scopy.deleteAtoms(self.markers)
        return scopy.atom_total, scopy.total_weight 
[docs]    def setStructure(self):
        """
        Set the structure for this row.
        """
        selection = self.template_selector.getSelection()
        if selection == NONE_ITEM:
            self.structure = None
            self.num_atoms = self.mol_weight = 0
            self.markers = []
            self.eta_indexes = set()
            self.status_label.unset()
            self.name_edit.clear()
            self.name_edit.setEnabled(False)
            return
        self.name_edit.setEnabled(True)
        # Don't post a warning if this is called as a result of the template
        # combo being set to Custom and the sketcher is empty
        self.quiet_if_empty = selection == CUSTOM_TEMPLATE
        super().setStructure()
        self.quiet_if_empty = False
        if self.structure:
            self.num_atoms, self.mol_weight = self.getAtomsAndWeight()
            if self.name_edit:
                self.name_edit.setText(self.structure.title)
        else:
            self.num_atoms = self.mol_weight = 0
            if self.name_edit:
                self.name_edit.clear()
        self.dataChanged.emit() 
[docs]    def getStructure(self):
        """
        Get the structure either from the sketcher or from a template file
        """
        template_name = str(self.template_selector.getSelection())
        if template_name == CUSTOM_TEMPLATE:
            self.structure = self.sketcher.getSketcherStructure(
                quiet_if_empty=self.quiet_if_empty)
        else:
            self.structure = self.sketcher.readTemplateStructure(template_name) 
[docs]    def updateTemplates(self, templates, select):
        """
        Load a new list of templates into the template list
        :type templates: list
        :param templates: list of template names
        :type select: str
        :param select: Attempt to select this template in the combo after
            loading new names
        """
        if select == RESELECT_CURRENT_TEMPLATE:
            select = self.template_selector.getSelection()
        if self.none_item and NONE_ITEM not in templates:
            templates += [NONE_ITEM]
        self.template_selector.silentlySetTemplates(templates)
        self.template_selector.silentlyTryToSelectTemplate(select)
        if str(self.template_selector.getSelection()) != CUSTOM_TEMPLATE:
            self.setStructure() 
[docs]    def getName(self, no_h=False):
        """
        Get the name the user has specified for this item
        :type no_h: bool
        :param no_h: If True, return an empty string if the name is "H"
        :rtype: str
        :return: The name for this row
        """
        if not self.name_edit:
            return ""
        name = str(self.name_edit.text())
        if no_h and name == 'H':
            name = ""
        return name 
[docs]    def getASLName(self):
        """
        Modify the user's name to make it acceptable for ASL syntax, which
        reserves some characters.
        :rtype: str
        :return: The name for this row modified to pass ASL syntax
        """
        asl_name = self.getName()
        for special in [' ', ',', '-', '\t', '>', '<', '=', '*', '?', '#']:
            asl_name = asl_name.replace(special, "")
        return asl_name 
[docs]    def hasStructure(self):
        """
        Is a structure set for this row?
        :rtype: bool
        :return: True if yes, False if no
        """
        return bool(self.structure) 
[docs]    def createSketcher(self):
        """
        Create a new Sketcher for this row
        """
        self.sketcher = self.sketcher_class(self.master,
                                            self.built_in_path,
                                            self.template_dirname,
                                            None,
                                            single_rx_atom=self.single_rx_atom)  
[docs]class ComplexLigandRxMixin(object):
[docs]    def validateRAtomIdentity(self, rx_atoms, max_x):
        """
        Run validation on the values of the R atoms. An error message is
        displayed to the user by this method if appropriate
        :type rx_atoms: dict
        :param rx_atoms: keys are the int value of x in Rx, values are lists of
            atom indexes set to that Rx value (atom indexes are 1-based)
        :type max_x: int
        :param max_x: The larget value of x in the keys of rx_atoms. Unused,
            kept for API compatibility with parent class.
        :rtype: bool
        :return: True if everything is OK, False if not
        """
        msg = ('Bond coordination sites must be marked with a subsituent '
               'labeled R1 for the first coordination site and R2 for the '
               'second coordination site (if applicable). Both covalent bonding'
               ' and dative bonding sites should be marked by an Rx subsituent.'
               ' %s-coordination should be designated by bonding the '
               'appropriate Rx site to all %s-coordinating atoms for at site. '
               'No value of R other than 1 or 2 should be used, and no value '
               'should be used more than once. %s' %
               (swidgets.GL_eta, swidgets.GL_eta, MARKING_INFO))
        valid = True
        r1_atoms = len(rx_atoms.get(1, []))
        r2_atoms = len(rx_atoms.get(2, []))
        allowed = {1, 2}
        if not rx_atoms:
            # Nothing marked
            valid = False
        elif any(True for x in (r1_atoms, r2_atoms) if x > 1):
            # Can't have more than one of the same Rx
            valid = False
        elif not r1_atoms:
            # Must have at least one of R1
            valid = False
        elif any(x for x in rx_atoms.keys() if x not in allowed):
            # Only 1 and 2 are allowed x values
            valid = False
        if not valid:
            self.warning(msg)
        return valid 
[docs]    def validateRAtomStructure(self, struct, rx_atoms, max_x):
        """
        Run validation on the R atoms that requires a
        `schrodinger.structure.Structure` object.
        :type struct: `schrodinger.structure.Structure`
        :param struct: The structure to use for validating the Ra atoms
        :type rx_atoms: dict
        :param rx_atoms: keys are the int value of x in Rx, values are lists of
            atom indexes set to that Rx value (atom indexes are 1-based)
        :type max_x: int
        :param max_x: The larget value of x in the keys of rx_atoms. Unused,
            kept for API compatibility with parent class.
        :rtype: bool
        :return: True if everything is OK, False if not
        """
        eta_indexes = get_eta_marker_indexes(struct)
        markers = []
        for xval, indexes in rx_atoms.items():
            markers.extend(indexes)
        # Check for the same atom neighbor for 2 Rx atoms
        all_atoms = set()
        for index in markers:
            for atom in struct.atom[index].bonded_atoms:
                if atom.index in all_atoms:
                    self.warning('Multiple R1,2 atoms may not be bonded to the '
                                 'same atom')
                    return False
                all_atoms.add(atom.index)
        # Check to ensure that ETA markers don't break up actual bonding
        # patterns
        for index in eta_indexes:
            scopy = struct.copy()
            scopy.deleteAtoms([index])
            if scopy.mol_total > 1:
                self.warning('Rx markers for eta-coordination can only be '
                             'bonded to atoms that are connected to each other')
                return False
        return True  
[docs]class ComplexTemplateSketcher(ComplexLigandRxMixin, SketcherBox):
    """
    A 2D sketcher that is decorated with a number of widgets for creating and
    saving templates. Overrides the parent class mainly for validation of the
    template structure.
    This class is specifically for templates for the Single and Multi complex
    builders, which use a different directory name for historical reasons
    """
[docs]    def __init__(self, *args, **kwargs):
        args = list(args)
        args[2] = 'complex_templates'
        SketcherBox.__init__(self, *args, **kwargs)
        self.moveOldTemplates() 
[docs]    def moveOldTemplates(self):
        """
        Move any templates from the old location to the new one that is used
        starting in 2014-3
        """
        old_dir = os.path.join(self.custom_path, os.pardir, os.pardir,
                               'complex_templates')
        if os.path.exists(old_dir):
            for filename in glob.iglob(old_dir + '/*'):
                filebase = os.path.basename(filename)
                new_path = os.path.join(self.custom_path, filebase)
                if os.path.exists(new_path):
                    fileutils.force_remove(filename)
                else:
                    fileutils.force_rename(filename, new_path)
            fileutils.force_rmtree(old_dir)
        return True  
[docs]class LigandSketcherStructGetter(ComplexLigandRxMixin, TMLigandRowMixin,
                                 MinimizeMixin, SketcherStructureMixin):
    """
    Gets a structure from the sketcher, marks the attachment points, checks for
    valid attachment points and creates a 3D structure.
    """
[docs]    def __init__(self, sketcher, master):
        """
        Create a LigandSketcherStructGetter instance
        :type sketcher: `schrodinger.ui.sketcher.sketcher`
        :param sketcher: The sketcher instance
        :type master: QWidget
        :param master: A widget with a warning method
        """
        self.master = master
        self.warning = master.warning
        self.sketcher = sketcher 
[docs]    def getStructure(self):
        """
        Get the structure from the sketcher, validate it, and convert it to 3D
        :note: Note that various methods called from here will post warning
            dialogs using the master widget (passed into the __init__ method)
            warning method when things go wrong.
        :rtype: `schrodinger.structure.Structure` or None
        :return: The 3D structure or None if an error occured.
        """
        self.structure = self.getSketcherStructure()
        if not self.structure:
            return None
        self.findAttachmentMarkers()
        # Do this before adding hydrogens because it converts marker atoms to H
        # or Dummies so we don't add H to them.
        self.prepareStructureForMinimization()
        # The sketcher structure doesn't include hydrogen atoms by default
        self.addHydrogens()
        self.convert2DTo3DAndMinimize()
        self.postTreatMinimizedStructure(marker_element='Du')
        return self.structure