"""
Contains widgets for MatSci jaguar-related panels.
Copyright Schrodinger, LLC. All rights reserved.
"""
import functools
from schrodinger.application.jaguar import basis as jag_basis
from schrodinger.application.jaguar import input as jagin
from schrodinger.application.jaguar.gui.tabs import solvation_tab
from schrodinger.application.jaguar.gui import basis_selector
from schrodinger.application.jaguar.gui import theory_selector
from schrodinger.application.jaguar.gui.tabs import optimization_tab
from schrodinger.application.jaguar.gui.tabs import scf_tab
from schrodinger.application.matsci import msutils
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.ui.qt import appframework
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.appframework2 import af2
from schrodinger.utils import fileutils
from schrodinger.utils import preferences
SCHRODINGER_PRESET = 'Schrodinger'
DEFAULT_PRESET = 'default'
DEFAULT_STR = '  (default)'
NO_SOLVENT = 'None'
# Jaguar 'solvent' keyword
SOLVENT_KEY = mm.MMJAG_SKEY_SOLVENT
# Jaguar 'isolv' keyword
MODEL_KEY = mm.MMJAG_IKEY_ISOLV
[docs]class BasisSetSelector(swidgets.SelectorWithPopUp):
    """
    A frame that allows the user to specify a basis from a pop up list
    """
    TOOL_BUTTON_CLASS = basis_selector.BasisSelectorFilterListToolButton
[docs]    def selectionChanged(self):
        """
        Set the line edit to a newly selected basis set
        """
        self.selection_le.setText(str(self.tool_btn.getBasis())) 
[docs]    def setBasis(self, basis):
        """
        Set the basis for the widget
        :param str basis: The basis to set
        :raise ValueError: If basis is invalid
        """
        self.tool_btn.setBasis(basis)
        # Use the same capitalization as the list widget (MATSCI-10004)
        self.selection_le.setText(self.tool_btn.getBasis()) 
[docs]    def reset(self):
        """
        Reset the widget
        """
        self.setBasis(self.default_selection) 
[docs]    def setStructure(self, struct):
        """
        Set the structure to determine valid basis sets
        :param `structure.Structure` struct: The structure to set
        """
        self.tool_btn.setStructure(struct) 
    @af2.validator()
    def validate(self):
        """
        Check if the basis set is valid
        :rtype: (False, msg) or True
        :return: False and error message if something is wrong, True if
            everything is OK
        """
        if not self.tool_btn.hasAcceptableInput():
            return (False, 'The specified basis set is not valid for the '
                    'input structure.')
        return True 
[docs]class TheorySelector(swidgets.SelectorWithPopUp):
    """
    A frame that allows the user to specify a theory from a pop up list
    """
    TOOL_BUTTON_CLASS = theory_selector.DftTheorySelectorFilterListToolButton
[docs]    def selectionChanged(self):
        """
        Set the line edit to the newly selected theory
        """
        self.selection_le.setText(str(self.tool_btn.getMethod())) 
[docs]    def setTheory(self, theory):
        """
        Set the theory for the widget
        :param str theory: The theory to set
        :raise ValueError: If the theory is not valid
        """
        self.clearFilters()  # MATSCI-9750
        valid = self.tool_btn.setMethod(theory)
        if valid:
            # Use the same capitalization as the list widget (MATSCI-10004)
            self.selection_le.setText(self.tool_btn.getMethod())
        else:
            raise ValueError(f'"{theory}" is not in the '
                             'list of available theories.') 
[docs]    def reset(self):
        """
        Reset the widget
        """
        self.setTheory(self.default_selection)  
[docs]class KeywordEdit(swidgets.SLabeledEdit):
    """
    A labeled edit for displaying, editing and retrieving Jaguar keywords
    """
[docs]    def __init__(self,
                 label_text="",
                 keyword_dict=None,
                 keyword_string="",
                 **kwargs):
        """
        Create a KeywordEdit instance.
        Any unrecognized keyword arguments are passed to the SLabeledEdit class
        :type label_text: str
        :param label_text: The text of the label for the KeywordEdit.  By
            default, there is no label.
        :type keyword_dict: dict
        :param keyword_dict: A dictionary of keyword/value pairs for the
            KeywordEdit to display.  If both keyword_dict and keyword_string are
            supplied, the keyword_dict keywords appear first.
        :type keyword_string: str
        :param keyword_string: The string to display in the KeywordEdit. If both
            keyword_dict and keyword_string are supplied, the keyword_dict keywords
            appear first.
        """
        if 'stretch' not in kwargs:
            kwargs['stretch'] = False
        swidgets.SLabeledEdit.__init__(self, label_text, **kwargs)
        if not label_text:
            self.label.hide()
        self.setKeywords(keyword_dict=keyword_dict,
                         keyword_string=keyword_string)
        self.default_text = str(self.text())
        # This moves the text cursor to the beginning of the line
        # of QtWidgets.QLineEdit
        self.home(True) 
[docs]    def getKeywordString(self):
        """
        Return the keyword string in the QLineEdit
        :rtype: str
        :return: The string in the QLineEdit.  No validity checking is done.
        """
        return str(self.text().lower()) 
[docs]    def getKeywordDict(self, keystring=None):
        """
        Return a dictionary whose keys are keywords and values are keyword
        values
        :type keystring: str
        :param keystring: If provided, the keywords are taken from this string
            rather than the QLineEdit. The default is to take the keywords from the
            QLineEdit
        :rtype: dict
        :return: Dictionary of keyword/value pairs
        :raise ValueError: if any tokens do not match the keyword=value format
        """
        if keystring is None:
            keystring = self.getKeywordString()
        return msutils.keyword_string_to_dict(keystring) 
[docs]    def setKeywords(self, keyword_dict=None, keyword_string=""):
        """
        Set the text of the KeywordEdit
        :type keyword_dict: dict
        :param keyword_dict: A dictionary of keyword/value pairs for the
            KeywordEdit to display.  If both keyword_dict and keyword_string are
            supplied, the keyword_dict keywords appear first.
        :type keyword_string: str
        :param keyword_string: The string to display in the KeywordEdit. If both
            keyword_dict and keyword_string are supplied, the keyword_dict keywords
            appear first.
        """
        if keyword_dict:
            keyword_text = ' '.join(
                ['%s=%s' % (x, y) for x, y in keyword_dict.items()])
        else:
            keyword_text = ""
        keyword_string = keyword_string + keyword_text
        self.setText(keyword_string) 
[docs]    def validateKeywords(self, emptyok=False):
        """
        Validate the contents to ensure they are valid Jaguar keywords. The
        return value of this is compatible with appframework2 validation
        methods - i.e. an af2 validation method can just call:
        return self.keyword_le.validateKeywords()
        :type emptyok: bool
        :param emptyok: Whether it is OK for the keyword input to be empty
        :rtype: True or (False, str)
        :return: True if no errors are found, otherwise a tuple containing False
            and an error message.
        """
        keywords = self.getKeywordString()
        if not emptyok and not keywords:
            return (False, 'Keyword input must not be empty')
        return True  
[docs]class CompactSolventSelector(swidgets.SFrame):
    """
    A single line of widgets that displays the currently chosen solvent and a
    button that will open a dialog allowing a new solvent model/solvent choice.
    Tracks the necessary Jaguar keywords to implement the user's choice.
    """
    solventChanged = QtCore.pyqtSignal()
[docs]    def __init__(self,
                 parent=None,
                 layout=None,
                 indent=False,
                 keywords=None,
                 **extra_args):
        """
        Create a CompactSolventSelector object
        Additional keyword arguments are passed on to the SolventDialog that is
        opened by this widget.
        :type layout: QBoxLayout
        :param layout: The layout to place this widget into
        :param bool indent: If true indent frame layout
        :type keywords: dict
        :param keywords: Dictionary of solvent-related Jaguar key/value pairs to
            initialize/reset the widgets with
        """
        super().__init__(parent=parent,
                         layout=layout,
                         indent=indent,
                         layout_type=swidgets.HORIZONTAL)
        self.other_solvent_options = {}
        if keywords is None:
            self.keywords = {}
        else:
            self.keywords = keywords
        self.default_keywords = self.keywords.copy()
        self.label_label = swidgets.SLabel('Solvent:', layout=self.mylayout)
        sname = self.getSolventName()
        self.solvent_label = swidgets.SLabel(sname, layout=self.mylayout)
        self.choose_btn = swidgets.SPushButton('Choose...',
                                               command=self.chooseSolvent,
                                               layout=self.mylayout)
        self.dialog_args = extra_args
        self.mylayout.addStretch() 
[docs]    def chooseSolvent(self):
        """
        Open a dialog that lets the user choose solvent parameters (model,
        solvent, solvent properties) and store the choices
        """
        dialog = SolventDialog(self, keywords=self.keywords, **self.dialog_args)
        dialog.keywordsChanged.connect(self.solventKeywordsChanged)
        dialog.exec_() 
[docs]    def solventKeywordsChanged(
            self,
            keywords,
            options={},  # noqa: M511
            update=False):
        """
        Called when the user clicks accept on the SolventDialog
        :type keywords: dict
        :param keywords: A dictionary of Jaguar solvent model keywords
        :param bool update: whether keywords should be updated or replaced
        """
        if update:
            self.keywords.update(keywords)
        else:
            self.keywords = keywords.copy()
        self.other_solvent_options = options
        self.solvent_label.setText(self.getSolventName())
        self.solventChanged.emit() 
[docs]    def isSolventModelUsed(self):
        """
        Has a solvent model been chosen?
        :rtype: bool
        :return: True if yes, False if no
        """
        return bool(self.keywords.get(MODEL_KEY, 0)) 
[docs]    def getSolventName(self):
        """
        Get the name of the chosen solvent
        :rtype: str
        :return: The user-facing name of the chosen solvent, or NO_SOLVENT if no
            model has been chosen
        """
        if self.isSolventModelUsed():
            return self.keywords[SOLVENT_KEY]
        else:
            return NO_SOLVENT 
[docs]    def getKeystring(self):
        """
        Get a string containing all the keywords specified by the user's choices
        :rtype: (str, str)
        :return: First item is a string containing keywords that define the
            user's choices. An empty string is returned if no model has been
            selected. The second item is a string containing any keywords set
            for solvent=other.
        """
        if self.isSolventModelUsed():
            keywords = self.keywords.copy()
            if SOLVENT_KEY in keywords:
                keywords[SOLVENT_KEY] = keywords[SOLVENT_KEY].replace(" ", '_')
            skeywords = msutils.keyword_dict_to_string(keywords)
        else:
            skeywords = ""
        oskeywords = ""
        oskeywords = msutils.keyword_dict_to_string(self.other_solvent_options)
        return (skeywords, oskeywords) 
[docs]    def reset(self):
        """
        Reset all the widgets to their original values
        """
        self.solventKeywordsChanged(self.default_keywords)  
[docs]class SolventDialog(QtWidgets.QDialog):
    """
    A Dialog that allows the user to pick a solvent model, solvent and
    parameters.
    Emits a keywordsChanged signal when the user clicks Accept and passes a
    dictionary of the Jaguar keywords that reflect the selected settings.
    """
    keywordsChanged = QtCore.pyqtSignal(dict, dict)
[docs]    def __init__(self, parent, keywords=None, **extra_args):
        """
        Create a SolventDialog object
        Additional keyword arguments are passed to the EmbeddedSolventWidget
        object
        :type parent: QWidget
        :param parent: The parent widget for this dialog
        :type keywords: dict
        :param keywords: A dictionary of jaguar key/value pairs that define the
            initial solvent settings for the dialog
        """
        self.master = parent
        QtWidgets.QDialog.__init__(self, parent)
        self.setWindowTitle('Solvent Model')
        layout = swidgets.SVBoxLayout(self)
        layout.setContentsMargins(6, 6, 6, 6)
        if keywords is None:
            self.keywords = {}
        else:
            self.keywords = keywords
        self.solvent_widgets = EmbeddedSolventWidget(keywords=self.keywords,
                                                     **extra_args)
        layout.addWidget(self.solvent_widgets)
        dbb = QtWidgets.QDialogButtonBox
        dialog_buttons = dbb(dbb.Save | dbb.Cancel | dbb.Help)
        dialog_buttons.accepted.connect(self.accept)
        dialog_buttons.rejected.connect(self.reject)
        dialog_buttons.helpRequested.connect(self.help)
        layout.addWidget(dialog_buttons) 
[docs]    def help(self):
        """
        Show the Jaguar solvent help
        """
        appframework.help_dialog('JAGUAR_TOPIC_SOLVATION_FOLDER', parent=self) 
[docs]    def accept(self):
        """
        Gather the options and emit a keyword dictionary with the
        keywords/values they define, then close the dialog.
        """
        keywords = self.solvent_widgets.getMmJagKeywords()
        for key, value in list(keywords.items()):
            if value is None:
                del keywords[key]
        # The solvation tab no longer allows for "other" solvents, so there are
        # no other solvent options to emit
        options = {}
        self.keywordsChanged.emit(keywords, options)
        return QtWidgets.QDialog.accept(self)  
[docs]class NewPresetDialog(swidgets.SDialog):
    """
    Dialog for getting the name for a new preset
    """
[docs]    def layOut(self):
        """
        Lay out the widgets for the dialog
        """
        layout = self.mylayout
        self.name_le = swidgets.SLabeledEdit('Option set name:', layout=layout)
        self.keyword_lbl = swidgets.SLabel('', layout=layout)
        self.keyword_lbl.setWordWrap(True) 
[docs]    def accept(self):
        """
        Overwrite parent's accept method to check if the name is valid
        """
        name = self.name_le.text()
        if not name:
            self.error('The name cannot be empty.')
            return
        if not fileutils.is_valid_jobname(name) or not name[0].isalpha():
            self.error('The name should start with a letter and can only'
                       ' contain the following special characters: "." "_" "-"')
            return
        if name in {SCHRODINGER_PRESET, DEFAULT_PRESET}:
            self.error(f'The name cannot be "{SCHRODINGER_PRESET}" or '
                       f'"{DEFAULT_PRESET}".')
            return
        if DEFAULT_STR in name:
            self.error(f'The name cannot contain "{DEFAULT_STR}".')
            return
        if name in self.existing_names:
            msg = ('An option set with the specified name already exists. '
                   'Overwrite?')
            if not messagebox.show_question(
                    parent=self, text=msg, title='Overwrite option set?'):
                return
        return super().accept() 
[docs]    @classmethod
    def getName(cls, master, keywords, existing_names):
        """
        Get a name from the user for the new preset
        :param `QtWidget` master: The parent widget
        :param str keywords: The keywords in string format
        :param set existing_names: Names for existing presets
        :rtype: str or None
        :return: The preset name, or None if the user cancels
        """
        dlg = cls(master, title='New Option Set')
        dlg.keyword_lbl.setText('Keywords: ' + keywords)
        dlg.existing_names = existing_names
        if dlg.exec_():
            return dlg.name_le.text()
        return None  
[docs]class PresetsDialog(swidgets.SDialog):
    """
    Dialog for saving and applying jaguar keyword presets and setting default
    preset
    """
[docs]    def __init__(self,
                 master,
                 *,
                 keyword_setter,
                 keyword_getter,
                 keyword_validator=None,
                 schrodinger_defaults_str='',
                 title='Jaguar Option Sets',
                 preferences_group='',
                 **kwargs):
        """
        :param QDialog master: The parent dialog
        :param callable keyword_setter: The function to call to set keywords.
            Should take keywords in string format as a single argument
        :param callable keyword_getter: The function to call to get keywords.
            Should return keywords in string format
        :param callable keyword_validator: The function to call to validate
            keywords. Should return a `af2.validation.ValidationResult` object
        :param str schrodinger_defaults_str: Default keywords for the
            Schrodinger preset in string format
        :param str preferences_group: The preference group to use for storing
            the presets. If not provided, a name will be created from the
            parent's window title. NOTE: duplicate titles will break things when
            not specifying a preferences_group
        :param str title: The title for the dialog
        """
        std_buttons = [
            QtWidgets.QDialogButtonBox.Close, QtWidgets.QDialogButtonBox.Help
        ]
        super().__init__(master,
                         title=title,
                         standard_buttons=std_buttons,
                         **kwargs)
        self.setWindowModality(QtCore.Qt.WindowModal)
        self.keyword_setter = keyword_setter
        self.keyword_getter = keyword_getter
        self.keyword_validator = keyword_validator
        self.schrodinger_defaults_str = schrodinger_defaults_str
        self.preferences_group = preferences_group
        self.setUpPreferences()
        self.populateList()
        size_hint = self.sizeHint()
        size_hint.setWidth(320)
        self.resize(size_hint) 
[docs]    def layOut(self):
        """
        Lay out the widgets for the dialog
        """
        layout = self.mylayout
        hlayout = swidgets.SHBoxLayout(layout=layout)
        self.presets_listw = swidgets.SListWidget(
            layout=hlayout,
            nocall=True,
            selection_command=self.listSelectionChanged)
        min_policy = QtWidgets.QSizePolicy.Minimum
        button_layout = swidgets.SVBoxLayout(layout=hlayout)
        self.new_btn = swidgets.SPushButton('New...',
                                            command=self.newPreset,
                                            layout=button_layout,
                                            fixed=min_policy)
        button_layout.addStretch()
        self.apply_btn = swidgets.SPushButton('Apply',
                                              layout=button_layout,
                                              command=self.applyPreset,
                                              fixed=min_policy)
        self.default_btn = swidgets.SPushButton('Set as Default',
                                                command=self.setDefaultPreset,
                                                layout=button_layout)
        self.del_btn = swidgets.SPushButton('Delete',
                                            layout=button_layout,
                                            command=self.deletePreset,
                                            fixed=min_policy) 
[docs]    def setUpPreferences(self):
        """
        Set up the preferences for the presets
        """
        self.prefs = preferences.Preferences(preferences.SCRIPTS)
        if not self.preferences_group:
            group = self.master.windowTitle().replace(' ', '_') + '_PRESETS'
        else:
            group = self.preferences_group
        self.prefs.beginGroup(group)
        self.prefs.set(SCHRODINGER_PRESET, self.schrodinger_defaults_str)
        if not self.prefs.contains(DEFAULT_PRESET):
            self.prefs.set(DEFAULT_PRESET, SCHRODINGER_PRESET) 
[docs]    def getCustomPresetsNames(self):
        """
        Get a list of custom preset names
        :rtype: list
        :return: list of custom preset names
        """
        custom_presets = [
            p for p in self.prefs.getAllPreferences()
            if p not in {SCHRODINGER_PRESET, DEFAULT_PRESET}
        ]
        # Sort case-insensitive alphabetically
        custom_presets = sorted(custom_presets, key=lambda x: x.lower())
        return custom_presets 
[docs]    def populateList(self):
        """
        Populate the list with presets
        """
        self.presets_listw.clear()
        presets = ['Schrodinger'] + self.getCustomPresetsNames()
        for preset in presets:
            if preset == self.getDefaultPresetName():
                preset += DEFAULT_STR
            self.presets_listw.addItem(preset)
        self.listSelectionChanged() 
[docs]    def getSelectedPreset(self):
        """
        Get the selected preset in the list, removing "(default)" if it is the
        default preset
        :rtype: str or None
        :return: The name of the selected preset or None if there's no selection
        """
        selections = self.presets_listw.selectedText()
        if len(selections) == 1:
            return selections[0].replace(DEFAULT_STR, '')
        return None 
[docs]    def listSelectionChanged(self):
        """
        Update the widgets based on the selected preset in the list
        """
        preset_name = self.getSelectedPreset()
        if not preset_name:
            self.apply_btn.setEnabled(False)
            self.default_btn.setEnabled(False)
            self.del_btn.setEnabled(False)
            return
        self.apply_btn.setEnabled(True)
        is_default = (preset_name == self.getDefaultPresetName())
        self.default_btn.setEnabled(not is_default)
        is_schrodinger = (preset_name == SCHRODINGER_PRESET)
        self.del_btn.setEnabled(not is_schrodinger) 
[docs]    def newPreset(self):
        """
        Ask the user for a new preset name and save the current keywords as
        a new preset
        """
        # Check if the keywords are valid before saving
        if self.keyword_validator:
            validation_result = self.keyword_validator()
            if not validation_result:
                self.error(validation_result.message)
                return
        # Ask the user for a name
        keywords = self.keyword_getter()
        existing_names = set(self.getCustomPresetsNames())
        name = NewPresetDialog.getName(self, keywords, existing_names)
        if not name:
            # The user cancelled
            return
        self.prefs.set(name, keywords)
        self.populateList() 
[docs]    def setDefaultPreset(self, *, preset_name=None):
        """
        Set the default preset. If preset_name is provided, set the default to
        it. Otherwise use the selected preset.
        :type preset_name: str or None
        :param str: preset_name: The name of the preset, or None
        """
        if preset_name is None:
            # Called by "Set as Default" button. Use selected preset.
            preset_name = self.getSelectedPreset()
        self.prefs.set(DEFAULT_PRESET, preset_name)
        self.populateList()
        # Select the default preset
        self.presets_listw.setTextSelected(preset_name + DEFAULT_STR) 
[docs]    def applyPreset(self):
        """
        Apply the selected preset
        """
        preset_name = self.getSelectedPreset()
        self.keyword_setter(self.prefs.get(preset_name)) 
[docs]    def deletePreset(self):
        """
        Delete the selected preset after confirming with the user
        """
        preset_name = self.getSelectedPreset()
        msg = f'Are you sure you want to delete the "{preset_name}" option set?'
        if not messagebox.show_question(
                parent=self, text=msg, title='Delete option set?'):
            return
        if preset_name == self.getDefaultPresetName():
            # Set default to Schrodinger
            self.setDefaultPreset(preset_name=SCHRODINGER_PRESET)
        self.prefs.remove(preset_name)
        self.populateList() 
[docs]    def getDefaultPresetName(self):
        """
        Get the name of the current default preset
        :rtype: str
        :return: The name of the current default
        """
        return self.prefs.get(DEFAULT_PRESET) 
[docs]    def getDefaultKeywords(self):
        """
        Get the keywords for the default preset
        :rtype: str
        :return: The keywords for the default preset
        """
        return self.prefs.get(self.getDefaultPresetName())  
[docs]class JaguarOptionsDialog(swidgets.SDialog):
    OVERWRITE_QUESTION_KEY = 'JAGOPTS_OVERWRITE_KEYWORDS'
    SPIN_RESTRICTED = 'Restricted'
    SPIN_UNRESTRICTED = 'Unrestricted'
    UHF_LABELS = {
        mm.MMJAG_IUHF_ON: SPIN_UNRESTRICTED,
        mm.MMJAG_IUHF_OFF: SPIN_RESTRICTED
    }
    DEFAULT_THEORY = 'B3LYP-D3'
    DEFAULT_BASIS_SET = '6-31G**'
    NON_ANALYTICAL_SCF_ACCURACIES = [
        val for val in scf_tab.ScfTab.ACCURACY_LEVELS.values()
        if val != scf_tab.ScfTab.FULLY_ANALYTIC_ACCURACY
    ]
    GEOPT_CONV_CRITERIA = {
        x: y
        for x, y in
        optimization_tab.OptimizationTab.CONVERGENCE_CRITERIA.items()
        if y != mm.MMJAG_IACCG_CUSTOM
    }
    OPTIMIZATION_KEYWORDS = [
        mm.MMJAG_IKEY_IGEOPT, mm.MMJAG_IKEY_MAXITG, mm.MMJAG_IKEY_IACCG,
        mm.MMJAG_RKEY_NOPS_OPT_SWITCH
    ]
    ALL_SOLVENT_KEYWORDS = {
        MODEL_KEY, SOLVENT_KEY, mm.MMJAG_SKEY_PCM_MODEL,
        mm.MMJAG_SKEY_PCM_RADII, mm.MMJAG_IKEY_PBF_AFTER_PCM,
        mm.MMJAG_IKEY_NOGAS
    }
    panelLabelChanged = QtCore.pyqtSignal(str)
[docs]    def __init__(self,
                 master,
                 button_label='Jaguar Options...',
                 title=None,
                 help_topic=None,
                 preference_group='',
                 layout=None,
                 show_optimization=True,
                 optional_optimization=False,
                 pass_optimization_keyword=True,
                 show_geopt_iterations=True,
                 show_spin_treatment=False,
                 show_charge=True,
                 show_multiplicity=True,
                 solvent_kwargs=None,
                 keyword_validator=None,
                 default_keywords=''):
        """
        Create a JaguarOptionsDialog instance
        :param QWidget master: The parent widget
        :param str button_label: The label for the button in the panel
        :param str title: The dialog title. If not provided, a title will be
            created from the panel's title
        :param str help_topic: The help topic for this dialog. If not provided,
            an id will be created from the panel's id
        :param str preferences_group: The preference group to use for storing
            the presets. If not provided, a name will be created from this
            dialog's title. NOTE: duplicate titles will break things when not
            specifying a preferences_group
        :param QLayout layout: The layout to add the panel widgets to
        :param bool show_optimization: Whether geometry optimization group
            should be shown
        :param bool optional_optimization: Whether geometry optimization is
            optional
        :param bool pass_optimization_keyword: Whether geometry optimization
            keyword (igeopt) should be returned when getting current keywords
        :param bool show_geopt_iterations: Whether maximum iterations for
            geometry optimization should be shown
        :param bool show_spin_treatment: Whether spin treatment rbg should be
            shown
        :param bool show_charge: Whether charge spinbox should be shown
        :param bool show_multiplicity: Whether multiplicity spinbox should be
            shown
        :type solvent_kwargs: None or dict
        :param solvent_kwargs: The kwargs to be passed to the solvent widget.
            If None, the widget won't be created. An empty dict can be passed to
            create the solvent widget with default kwargs.
        :param callable keyword_validator: Optional function to call to validate
            the keywords. Should raise KeyError if there are any issues with
            the keywords.
        :type default_keywords: str or dict
        :param default_keywords: The default keywords for the dialog
        """
        self.preference_group = preference_group
        self.show_optimization = show_optimization
        self.optional_optimization = optional_optimization
        self.pass_optimization_keyword = pass_optimization_keyword
        self.show_geopt_iterations = show_geopt_iterations
        self.show_spin_treatment = show_spin_treatment
        self.show_charge = show_charge
        self.show_multiplicity = show_multiplicity
        self.solvent_kwargs = solvent_kwargs
        self.keyword_validator = keyword_validator
        self.schrodinger_defaults = default_keywords
        # Create the button and label in the panel. The label should be created
        # before layOut is called
        hlayout = swidgets.SHBoxLayout(layout=layout)
        self.panel_edit_btn = swidgets.SPushButton(button_label,
                                                   layout=hlayout,
                                                   command=self.showForEdit)
        self.panel_lbl = swidgets.SLabel('', layout=hlayout)
        hlayout.addStretch()
        if title is None:
            title = 'Jaguar Options - ' + master.title
        if help_topic is None:
            help_topic = master.help_topic + '_JAGUAR_OPTIONS'
        dbb = QtWidgets.QDialogButtonBox
        buttons = [dbb.Ok, dbb.Cancel]
        super().__init__(master,
                         standard_buttons=buttons,
                         title=title,
                         help_topic=help_topic)
        self.setWindowModality(QtCore.Qt.WindowModal)
        self.setUpDefaults() 
[docs]    def layOut(self):
        """
        Lay out the widgets for the dialog
        """
        layout = self.mylayout
        top_layout = swidgets.SHBoxLayout(layout=layout)
        selector_frame = swidgets.SFrame(layout=top_layout)
        top_right_layout = swidgets.SVBoxLayout(layout=top_layout)
        swidgets.SPushButton('Option Sets...',
                             layout=top_right_layout,
                             command=self.showPresets)
        top_right_layout.addStretch(10)
        selector_frame.setContentsMargins(0, 15, 0, 0)
        self.theory_selector = TheorySelector('Theory:',
                                              self.DEFAULT_THEORY,
                                              layout=selector_frame.mylayout)
        self.basis_selector = BasisSetSelector('Basis set:',
                                               self.DEFAULT_BASIS_SET,
                                               layout=selector_frame.mylayout)
        for selector in (self.theory_selector, self.basis_selector):
            selector.selection_le.textChanged.connect(self.updatePanelLabel)
        self.scf_gb = swidgets.SGroupBox('SCF', parent_layout=layout)
        if self.show_spin_treatment:
            self.spin_treatment_rbg = swidgets.SLabeledRadioButtonGroup(
                group_label="Spin treatment:",
                labels=self.UHF_LABELS.values(),
                layout=self.scf_gb.layout)
        self.scf_accuracy_combo = swidgets.SLabeledComboBox(
            'Accuracy level:',
            itemdict=scf_tab.ScfTab.ACCURACY_LEVELS,
            layout=self.scf_gb.layout)
        self.scf_iterations_sb = swidgets.SLabeledSpinBox(
            'Maximum iterations:',
            minimum=1,
            value=48,
            maximum=999999999,
            layout=self.scf_gb.layout
        )  # Default, min and max are from scf_tab.ui
        if self.show_optimization:
            extra_kwargs = {}
            if self.optional_optimization:
                extra_kwargs = {'checkable': True, 'checked': False}
            self.geopt_gb = swidgets.SGroupBox('Geometry optimization',
                                               parent_layout=layout,
                                               **extra_kwargs)
            if self.show_geopt_iterations:
                self.geopt_iterations_sb = swidgets.SLabeledSpinBox(
                    'Maximum steps:',
                    value=100,
                    maximum=999999999,
                    layout=self.geopt_gb.layout)
            self.use_nops_cb = swidgets.SCheckBox(
                'Switch to analytic integrals near convergence',
                layout=self.geopt_gb.layout)
            self.geopt_accuracy_combo = swidgets.SLabeledComboBox(
                'Convergence criteria:',
                itemdict=self.GEOPT_CONV_CRITERIA,
                layout=self.geopt_gb.layout)
        self.no_fail_cb = swidgets.SCheckBox(
            'Use special measures to prevent convergence failure',
            checked=False,
            layout=layout)
        if self.show_charge:
            self.charge_sb = swidgets.SLabeledSpinBox('Charge:',
                                                      value=0,
                                                      minimum=-99,
                                                      maximum=99,
                                                      layout=layout)
        if self.show_multiplicity:
            self.multiplicity_sb = swidgets.SLabeledSpinBox('Multiplicity:',
                                                            value=1,
                                                            minimum=1,
                                                            maximum=99,
                                                            layout=layout)
        self.symmetry_cb = swidgets.SCheckBox('Use symmetry',
                                              layout=layout,
                                              checked=True,
                                              disabled_checkstate=False)
        # Disable the symmetry checkbox if the panel sets the keyword to 0
        symm_keyword = self.makeDict(self.schrodinger_defaults).get(
            mm.MMJAG_IKEY_ISYMM, mm.MMJAG_ISYMM_FULL)
        self.symmetry_cb.setEnabled(int(symm_keyword) != mm.MMJAG_ISYMM_OFF)
        if self.solvent_kwargs is not None:
            # Default keywords should not be supplied in kwargs, otherwise
            # resetting the solvent selector and then updating the keywords in
            # updateWidgets() will cause a mix of old and new keywords
            assert 'keywords' not in self.solvent_kwargs
            self.solvent_selector = CompactSolventSelector(
                layout=layout, **self.solvent_kwargs)
            self.solvent_selector.solventChanged.connect(self.updatePanelLabel)
        else:
            self.solvent_selector = None
        self.additional_keywords_le = KeywordEdit('Additional keywords:',
                                                  layout=layout,
                                                  stretch=False) 
    def _getWidgetKeywords(self, add_additional=True):
        """
        Get the current widget keywords
        :param bool add_additional: Whether additional keywords should also be
            added
        :rtype: dict
        :return: The widget keywords
        """
        # Theory and basis
        keywords = {
            mm.MMJAG_SKEY_DFTNAME: self.theory_selector.getSelection(),
            mm.MMJAG_SKEY_BASIS: self.basis_selector.getSelection()
        }
        # SCF
        if self.show_spin_treatment:
            if self.spin_treatment_rbg.checkedText() == self.SPIN_UNRESTRICTED:
                keywords[mm.MMJAG_IKEY_IUHF] = mm.MMJAG_IUHF_ON
            else:
                keywords[mm.MMJAG_IKEY_IUHF] = mm.MMJAG_IUHF_OFF
        accuracy = self.scf_accuracy_combo.currentData()
        if accuracy == scf_tab.ScfTab.FULLY_ANALYTIC_ACCURACY:
            keywords[mm.MMJAG_IKEY_NOPS] = mm.MMJAG_NOPS_ON
        else:
            keywords[mm.MMJAG_IKEY_NOPS] = mm.MMJAG_NOPS_OFF
            keywords[mm.MMJAG_IKEY_IACC] = accuracy
        keywords[mm.MMJAG_IKEY_MAXIT] = self.scf_iterations_sb.value()
        # Geometry optimization
        if self.show_optimization and (not self.optional_optimization or
                                       self.geopt_gb.isChecked()):
            if self.pass_optimization_keyword:
                keywords[mm.MMJAG_IKEY_IGEOPT] = mm.MMJAG_IGEOPT_MIN
            if self.show_geopt_iterations:
                keywords[
                    mm.MMJAG_IKEY_MAXITG] = self.geopt_iterations_sb.value()
            if self.use_nops_cb.isChecked():
                keywords[mm.MMJAG_RKEY_NOPS_OPT_SWITCH] = \
                    
optimization_tab.INITIAL_NOPS_VAL
            keywords[mm.MMJAG_IKEY_IACCG] = \
                
self.geopt_accuracy_combo.currentData()
        keywords[mm.MMJAG_IKEY_NOFAIL] = int(self.no_fail_cb.isChecked())
        if self.show_charge:
            keywords[mm.MMJAG_IKEY_MOLCHG] = self.charge_sb.value()
        if self.show_multiplicity:
            keywords[mm.MMJAG_IKEY_MULTIP] = self.multiplicity_sb.value()
        if self.symmetry_cb.isChecked():
            keywords[mm.MMJAG_IKEY_ISYMM] = mm.MMJAG_ISYMM_FULL
        else:
            keywords[mm.MMJAG_IKEY_ISYMM] = mm.MMJAG_ISYMM_OFF
        if self.solvent_selector:
            keywords_str = self.solvent_selector.getKeystring()[0]
            keywords.update(self.makeDict(keywords_str))
        if add_additional:
            keywords.update(self.additional_keywords_le.getKeywordDict())
        return keywords
[docs]    def setUpDefaults(self):
        """
        Create a `PresetsDialog` instance for this dialog and set and validate
        default keywords
        """
        def getter():
            return self.makeString(self._getWidgetKeywords())
        # save_as_current=False ensures cancelling after applying will undo
        # the changes
        setter = functools.partial(self.setKeywords, save_as_current=False)
        self.presets_dlg = PresetsDialog(
            self,
            keyword_getter=getter,
            keyword_setter=setter,
            keyword_validator=self.validate,
            schrodinger_defaults_str=self.makeString(self.schrodinger_defaults),
            preferences_group=self.preference_group,
            help_topic='JAGUAR_OPTIONS_PRESETS')
        # Update widgets with default keywords from PresetsDialog
        self.reset()
        # Validate default keywords
        validation_results = self.validate()
        if validation_results.message:
            self.error(validation_results.message) 
[docs]    def restrictJaguarTheoryOptions(self, text):
        """
        Restrict the level of theory option in jaguar dialog box by text.
        Text should be one of the predefined options. Acceptable text
        values are "Recommended", "Long range corrected DFT", "Hybrid DFT",
        "Meta GGA DFT", "GGA DFT", "LDA DFT"
        :type text: str
        :param text: specifier to select level of theory.
        """
        jag_tool_btn = self.theory_selector.tool_btn
        popup = jag_tool_btn._pop_up._predefined_filters_pop_up
        assert text in (cbs.text() for cbs in popup._filter_cbs)
        for cbs in popup._filter_cbs:
            if cbs.text() == text:
                cbs.setChecked(True)
                cbs.setEnabled(False)
        popup.filter_group_box.setCheckable(False) 
[docs]    def setEditEnabled(self, state):
        """
        Set the enabled state of the edit button in the panel and the label
        after it
        :param bool state: Whether the widgets should be enabled
        """
        self.panel_edit_btn.setEnabled(state)
        self.panel_lbl.setEnabled(state) 
[docs]    def updatePanelLabel(self):
        """
        Update the label in the panel with the new options
        """
        parts = []
        if self.solvent_selector:
            solvent_name = self.solvent_selector.getSolventName()
            if solvent_name != NO_SOLVENT:
                parts.append(solvent_name)
        parts.append(self.theory_selector.getSelection())
        parts.append(self.basis_selector.getSelection())
        new_text = '/'.join(parts)
        self.panel_lbl.setText(new_text)
        self.panelLabelChanged.emit(new_text) 
[docs]    def showForEdit(self):
        """
        Show the dialog
        """
        self.show()
        self.raise_() 
[docs]    def showPresets(self):
        """
        Show the presets dialog for this dialog
        """
        self.presets_dlg.show()
        self.presets_dlg.raise_() 
[docs]    def setKeywords(self, keywords, save_as_current=True):
        """
        Set the keywords for the dialog. Widgets that are not modified by the
        passed keywords will be reset to default.
        :type keywords: str or dict
        :param keywords: The keywords to set
        :param bool save_as_current: Whether the keywords should be saved so
            cancelling the dialog doesn't undo the changes
        """
        self.reset(keywords=self.makeDict(keywords),
                   save_as_current=save_as_current) 
[docs]    def getKeywordDict(self):
        """
        Get current keywords as a dict
        :rtype: dict
        :return: The current keywords as dict
        """
        return self.current_keywords 
[docs]    def getKeywordString(self):
        """
        Get current keywords as a string
        :rtype: str
        :return: The current keywords as a string
        """
        return self.makeString(self.current_keywords) 
[docs]    def accept(self):
        """
        Update the keywords and close the dialog if the inputs are valid
        """
        validation_result = self.validate()
        if validation_result.message:
            self.error(validation_result.message)
        elif validation_result:
            self.current_keywords = self._getWidgetKeywords()
            super().accept() 
[docs]    def reject(self):
        """
        Close the dialog and reset it back to when it was opened
        """
        super().reject()
        self.reset(keywords=self.current_keywords) 
[docs]    def reset(self, keywords=None, save_as_current=True):
        """
        Reset the dialog and update the widgets with any passed keywords
        :type keywords: dict or None
        :param keywords: The keywords to update widgets with
        :param bool save_as_current: Whether the keywords should be saved so
            cancelling the dialog doesn't undo the changes
        """
        self.theory_selector.reset()
        self.basis_selector.reset()
        if self.show_spin_treatment:
            self.spin_treatment_rbg.reset()
        self.scf_accuracy_combo.reset()
        self.scf_iterations_sb.reset()
        if self.show_optimization:
            if self.optional_optimization:
                self.geopt_gb.reset()
            if self.show_geopt_iterations:
                self.geopt_iterations_sb.reset()
            self.use_nops_cb.reset()
            self.geopt_accuracy_combo.reset()
        self.no_fail_cb.reset()
        if self.show_charge:
            self.charge_sb.reset()
        if self.show_multiplicity:
            self.multiplicity_sb.reset()
        self.symmetry_cb.reset()
        if self.solvent_selector:
            self.solvent_selector.reset()
        self.additional_keywords_le.reset()
        if keywords is None:
            keywords = self.makeDict(self.presets_dlg.getDefaultKeywords())
            self.current_keywords = keywords
        elif save_as_current:
            self.current_keywords = keywords
        self.updateWidgets(keywords) 
    @af2.validator()
    def validate(self):
        """
        Validate the dialog keywords
        :rtype: bool or (bool, str)
        :return: True if everything is OK, (False, msg) if the state is invalid
            and an error should be shown to the user in a warning dialog.
        """
        # Make sure the keyword syntax is valid
        try:
            additional_keywords = self.additional_keywords_le.getKeywordDict()
        except ValueError as err:
            return False, str(err)
        # Make sure all additional keywords and values are valid
        for key, val in additional_keywords.items():
            if hasattr(mm, 'MMJAG_IKEY_' + key.upper()):
                try:
                    int(val)
                except ValueError:
                    return False, (f'The "{key}" keyword '
                                   'requires an integer value.')
            elif hasattr(mm, 'MMJAG_RKEY_' + key.upper()):
                try:
                    float(val)
                except ValueError:
                    return False, (f'The "{key}" keyword requires'
                                   ' a floating point value.')
            elif not hasattr(mm, 'MMJAG_SKEY_' + key.upper()):
                pass
                # Cannot rely on the existing variables to see which keywords
                # exist (MATSCI-10453)
                # return False, f'"{key}" is not a Jaguar keyword.'
        # yapf: disable
        # Verify that all additional keywords are allowed
        optional_widget_keywords = (
            (self.show_optimization, self.OPTIMIZATION_KEYWORDS),
            (self.pass_optimization_keyword, [mm.MMJAG_IKEY_IGEOPT]),
            (self.show_geopt_iterations, [mm.MMJAG_IKEY_MAXITG]),
            (self.show_charge, [mm.MMJAG_IKEY_MOLCHG]),
            (self.show_multiplicity, [mm.MMJAG_IKEY_MULTIP]),
            (self.show_spin_treatment, [mm.MMJAG_IKEY_IUHF])
        )
        # yapf: enable
        for is_shown, keywords in optional_widget_keywords:
            if not is_shown:
                for keyword in keywords:
                    if keyword in additional_keywords:
                        return False, (f'The "{keyword}" keyword may '
                                       'not be used with this panel.')
        # Put any keywords supported by the widgets into them
        if not self.updateWidgets(additional_keywords):
            return False
        widget_keywords = self._getWidgetKeywords(add_additional=False)
        additional_keywords = self.additional_keywords_le.getKeywordDict()
        # Validate keywords using the custom validator, if any
        if self.keyword_validator:
            all_keywords = dict(widget_keywords)
            all_keywords.update(additional_keywords)
            try:
                self.keyword_validator(all_keywords)
            except KeyError as msg:
                return False, str(msg)
        # Check if any of the additional keywords is overwriting widget keywords
        overwrite = [k for k in additional_keywords if k in widget_keywords]
        if overwrite:
            msg = ('The following additional keywords will overwrite '
                   'existing keywords:\n' + '\n'.join(overwrite) +
                   '\n\nContinue?')
            if not messagebox.show_question(
                    parent=self,
                    text=msg,
                    title='Overwrite keywords?',
                    save_response_key=self.OVERWRITE_QUESTION_KEY):
                return False
        return True
    @af2.validator()
    def validateBasisSet(self, structs):
        """
        Validate that the passed structures are compatible with the current
        basis set
        :param iterable structs: The structures to validate
        :rtype: bool or (bool, str)
        :return: True if everything is OK, (False, msg) if the state is invalid
            and an error should be shown to the user.
        """
        basis_name = self.basis_selector.getSelection()
        for struct in structs:
            num_funcs = jag_basis.num_functions_all_atoms(basis_name, struct)[0]
            if num_funcs == 0:
                error = (f'The "{basis_name}" basis set is not valid'
                         f' for the "{struct.title}" structure.')
                return False, error
        return True
[docs]    @staticmethod
    def makeDict(keywords):
        """
        Create a keyword dictionary if the passed keywords are in string format
        :type keywords: str or dict
        :param keywords: Keyword dict or string
        :rtype: dict
        :return: Keyword dictionary
        """
        if isinstance(keywords, str):
            keywords = msutils.keyword_string_to_dict(keywords)
        return keywords 
[docs]    @staticmethod
    def makeString(keywords):
        """
        Create a keyword string if the passed keywords are in dictionary format
        :type keywords: str or dict
        :param keywords: Keyword dict or string
        :rtype: str
        :return: Keyword string
        """
        if isinstance(keywords, dict):
            keywords = msutils.keyword_dict_to_string(keywords)
        return keywords 
[docs]    def assertInKeywords(self, keywords):
        """
        Assert that the passed keywords are a subset of the dialog's keywords
        Used for unittests.
        :type keywords: str or dict
        :param keywords: Keyword dict or string
        """
        for key, val in self.makeDict(keywords).items():
            assert key in self.current_keywords, (f'{key} is not in '
                                                  f'{self.current_keywords}')
            actual_val = self.current_keywords[key]
            if isinstance(actual_val, str) and isinstance(val, str):
                actual_val = actual_val.lower()
                val = val.lower()
            assert val == actual_val, f'{val} != {actual_val}'