#!/usr/bin/env python# -*- coding: utf-8 -*-# To build simple dialogues etc. (requires pyqt4)## Part of the PsychoPy library# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.# Distributed under the terms of the GNU General Public License (GPL).importimportlibfrompsychopyimportlogging,datafrompsychopy.tools.arraytoolsimportIndexDictfrom.importutilhaveQt=False# until we confirm otherwiseimportOrder=['PyQt6','PyQt5']forlibnameinimportOrder:try:importlib.import_module(f"{libname}.QtCore")haveQt=libnamelogging.debug(f"psychopy.gui is using {haveQt}")breakexceptModuleNotFoundError:passifnothaveQt:# do the main import again not in a try...except to recreate errorimportPyQt6elifhaveQt=='PyQt6':fromPyQt6importQtWidgetsfromPyQt6importQtGuifromPyQt6.QtCoreimportQtelifhaveQt=='PyQt5':fromPyQt5importQtWidgetsfromPyQt5importQtGuifromPyQt5.QtCoreimportQtfrompsychopyimportloggingimportnumpyasnpimportosimportsysimportjsonfrompsychopy.localizationimport_translateqtapp=QtWidgets.QApplication.instance()defensureQtApp():globalqtapp# make sure there's a QApplication prior to showing a gui, e.g., for expInfo# dialogifqtappisNone:qtapp=QtWidgets.QApplication(sys.argv)qtapp.setStyle('Fusion')# use this to avoid annoying PyQt bug with OK being greyed-outwasMouseVisible=TrueclassReadmoreCtrl(QtWidgets.QLabel):""" A linked label which shows/hides a set of control on click. """def__init__(self,parent,label=""):QtWidgets.QLabel.__init__(self,parent)# set initial state and labelself.state=Falseself.label=labelself.updateLabel()# array to store linked ctrlsself.linkedCtrls=[]# bind onclickself.setOpenExternalLinks(False)self.linkActivated.connect(self.onToggle)defupdateLabel(self):""" Update label so that e.g. arrow matches current state. """# reset label to its own value to refreshself.setLabel(self.label)defsetLabel(self,label=""):""" Set the label of this ctrl (will append arrow and necessary HTML for a link) """# store label rootself.label=label# choose an arrow according to stateifself.state:arrow="▾"else:arrow="▸"# construct text to settext=f"<a href='.' style='color: black; text-decoration: none;'>{arrow}{label}</a>"# set label textself.setText(text)defonToggle(self,evt=None):""" Toggle visibility of linked ctrls. Called on press. """# toggle stateself.state=notself.state# show/hide linked ctrls according to stateforctrlinself.linkedCtrls:ifself.state:ctrl.show()else:ctrl.hide()# update labelself.updateLabel()# resize dlgself.parent().adjustSize()deflinkCtrl(self,ctrl):""" Connect a ctrl to this ReadmoreCtrl such that it's shown/hidden on toggle. """# add to array of linked ctrlsself.linkedCtrls.append(ctrl)# show/hide according to own stateifself.state:ctrl.show()else:ctrl.hide()# resize dlgself.parent().adjustSize()
[docs]classDlg(QtWidgets.QDialog):"""A simple dialogue box. You can add text or input boxes (sequentially) and then retrieve the values. see also the function *dlgFromDict* for an **even simpler** version **Example** .. code-block:: python from psychopy import gui myDlg = gui.Dlg(title="JWP's experiment") myDlg.addText('Subject info') myDlg.addField('Name:') myDlg.addField('Age:', 21) myDlg.addText('Experiment Info') myDlg.addField('Grating Ori:',45) myDlg.addField('Group:', choices=["Test", "Control"]) ok_data = myDlg.show() # show dialog and wait for OK or Cancel if myDlg.OK: # or if ok_data is not None print(ok_data) else: print('user cancelled') """def__init__(self,title=_translate('PsychoPy Dialog'),pos=None,size=None,style=None,labelButtonOK=_translate(" OK "),labelButtonCancel=_translate(" Cancel "),screen=-1,alwaysOnTop=False):ensureQtApp()QtWidgets.QDialog.__init__(self,None)self.inputFields=[]self.inputFieldTypes={}self.inputFieldNames=[]self.data=IndexDict()self.irow=0self.pos=pos# QtWidgets.QToolTip.setFont(QtGui.QFont('SansSerif', 10))# set always stay on topifalwaysOnTop:self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint)# add buttons for OK and Cancelbuttons=QtWidgets.QDialogButtonBox.StandardButton.Ok|QtWidgets.QDialogButtonBox.StandardButton.Cancelself.buttonBox=QtWidgets.QDialogButtonBox(buttons,parent=self)self.buttonBox.accepted.connect(self.accept)self.buttonBox.rejected.connect(self.reject)# store references to OK and CANCEL buttonsself.okBtn=self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok)self.cancelBtn=self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Cancel)ifstyle:raiseRuntimeWarning("Dlg does not currently support the ""style kwarg.")self.size=sizeifhaveQtin['PyQt5','PyQt6']:nScreens=len(qtapp.screens())else:nScreens=QtWidgets.QDesktopWidget().screenCount()self.screen=-1ifscreen>=nScreenselsescreen# self.labelButtonOK = labelButtonOK# self.labelButtonCancel = labelButtonCancelself.layout=QtWidgets.QGridLayout()self.layout.setSpacing(10)self.layout.setColumnMinimumWidth(1,250)# add placeholder for readmore control sizerself.readmore=None# add message about required fields (shown/hidden by validate)msg=_translate("Fields marked with an asterisk (*) are required.")self.requiredMsg=QtWidgets.QLabel(text=msg,parent=self)self.layout.addWidget(self.requiredMsg,0,0,1,-1)self.irow+=1self.setLayout(self.layout)self.setWindowTitle(title)defaddText(self,text,color='',isFieldLabel=False):textLabel=QtWidgets.QLabel(text,parent=self)iflen(color):textLabel.setStyleSheet("color: {0};".format(color))ifisFieldLabelisTrue:self.layout.addWidget(textLabel,self.irow,0,1,1)else:self.layout.addWidget(textLabel,self.irow,0,1,2)self.irow+=1returntextLabel
[docs]defaddField(self,key,initial='',color='',choices=None,tip='',required=False,enabled=True,label=None):"""Adds a (labelled) input field to the dialogue box, optional text color and tooltip. If 'initial' is a bool, a checkbox will be created. If 'choices' is a list or tuple, a dropdown selector is created. Otherwise, a text line entry box is created. Returns a handle to the field (but not to the label). """# if not given a label, use key (sans-pipe syntax)iflabelisNone:label,_=util.parsePipeSyntax(key)self.inputFieldNames.append(label)ifchoices:self.inputFieldTypes[label]=strelse:self.inputFieldTypes[label]=type(initial)iftype(initial)==np.ndarray:initial=initial.tolist()# create labelinputLabel=self.addText(label,color,isFieldLabel=True)# create input controliftype(initial)==boolandnotchoices:self.data[key]=initialinputBox=QtWidgets.QCheckBox(parent=self)inputBox.setChecked(initial)defhandleCheckboxChange(new_state):self.data[key]=inputBox.isChecked()msg="handleCheckboxChange: inputFieldName={0}, checked={1}"logging.debug(msg.format(label,self.data[key]))inputBox.stateChanged.connect(handleCheckboxChange)elifnotchoices:self.data[key]=initialinputBox=QtWidgets.QLineEdit(str(initial),parent=self)defhandleLineEditChange(new_text):ix=self.inputFields.index(inputBox)name=self.inputFieldNames[ix]thisType=self.inputFieldTypes[name]try:ifthisTypein(str,bytes):self.data[key]=str(new_text)elifthisType==tuple:jtext="["+str(new_text)+"]"self.data[key]=json.loads(jtext)[0]elifthisType==list:jtext="["+str(new_text)+"]"self.data[key]=json.loads(jtext)[0]elifthisType==float:self.data[key]=float(new_text)elifthisType==int:self.data[key]=int(new_text)elifthisType==dict:jtext="["+str(new_text)+"]"self.data[key]=json.loads(jtext)[0]elifthisType==np.ndarray:self.data[key]=np.array(json.loads("["+str(new_text)+"]")[0])else:self.data[key]=new_textmsg=("Unknown type in handleLineEditChange: ""inputFieldName={0}, type={1}, value={2}")logging.warning(msg.format(label,thisType,self.data[ix]))msg=("handleLineEditChange: inputFieldName={0}, ""type={1}, value={2}")logging.debug(msg.format(label,thisType,self.data[key]))exceptExceptionase:self.data[key]=str(new_text)msg=('Error in handleLineEditChange: inputFieldName=''{0}, type={1}, value={2}, error={3}')logging.error(msg.format(label,thisType,self.data[key],e))self.validate()inputBox.textEdited.connect(handleLineEditChange)else:inputBox=QtWidgets.QComboBox(parent=self)choices=list(choices)fori,optioninenumerate(choices):inputBox.addItem(str(option))# inputBox.addItems([unicode(option) for option in choices])inputBox.setItemData(i,(option,))if(isinstance(initial,(int,int))andlen(choices)>initial>=0):passelifinitialinchoices:initial=choices.index(initial)else:initial=0inputBox.setCurrentIndex(initial)self.data[key]=choices[initial]defhandleCurrentIndexChanged(new_index):ix=self.inputFields.index(inputBox)try:self.data[key]=inputBox.itemData(new_index).toPyObject()[0]exceptAttributeError:self.data[key]=inputBox.itemData(new_index)[0]msg=("handleCurrentIndexChanged: inputFieldName={0}, ""selected={1}, type: {2}")logging.debug(msg.format(label,self.data[key],type(self.data[key])))inputBox.currentIndexChanged.connect(handleCurrentIndexChanged)# set required (attribute is checked later by validate fcn)inputBox.required=requiredifcolorisnotNoneandlen(color):inputBox.setPalette(inputLabel.palette())iftipisnotNoneandlen(tip):inputBox.setToolTip(tip)inputBox.setEnabled(enabled)self.layout.addWidget(inputBox,self.irow,1)# link to readmore ctrl if we're in oneifself.readmoreisnotNone:self.readmore.linkCtrl(inputBox)self.readmore.linkCtrl(inputLabel)self.inputFields.append(inputBox)# store this to get data back on OKself.irow+=1returninputBox
[docs]defaddFixedField(self,key,label='',initial='',color='',choices=None,tip=''):"""Adds a field to the dialog box (like addField) but the field cannot be edited. e.g. Display experiment version. """returnself.addField(key=key,label=label,initial=initial,color=color,choices=choices,tip=tip,enabled=False)
defaddReadmoreCtrl(self):line=ReadmoreCtrl(self,label=_translate("Configuration fields..."))self.layout.addWidget(line,self.irow,0,1,2)self.irow+=1self.enterReadmoreCtrl(line)returnlinedefenterReadmoreCtrl(self,ctrl):self.readmore=ctrldefexitReadmoreCtrl(self):self.readmore=Nonedefdisplay(self):"""Presents the dialog and waits for the user to press OK or CANCEL. If user presses OK button, function returns a list containing the updated values coming from each of the input fields created. Otherwise, None is returned. :return: self.data """returnself.exec_()
[docs]defvalidate(self):""" Make sure that required fields have a value. """# start off assuming validvalid=True# start off assuming no required fieldshasRequired=False# iterate through fieldsforfieldinself.inputFields:# if field isn't required, skipifnotfield.required:continue# if we got this far, we have a required fieldhasRequired=True# validation is only relevant for text fields, others have defaultsifnotisinstance(field,QtWidgets.QLineEdit):continue# check that we have textifnotlen(field.text()):valid=False# if not valid, disable OK buttonself.okBtn.setEnabled(valid)# show required message if we have any required fieldsifhasRequired:self.requiredMsg.show()else:self.requiredMsg.hide()
[docs]defshow(self):"""Presents the dialog and waits for the user to press OK or CANCEL. If user presses OK button, function returns a list containing the updated values coming from each of the input fields created. Otherwise, None is returned. :return: self.data """# NB## ** QDialog already has a show() method. So this method calls# QDialog.show() and then exec_(). This seems to not cause issues# but we need to keep an eye out for any in future.returnself.display()
defexec_(self):"""Presents the dialog and waits for the user to press OK or CANCEL. If user presses OK button, function returns a list containing the updated values coming from each of the input fields created. Otherwise, None is returned. """self.layout.addWidget(self.buttonBox,self.irow,0,1,2)ifself.posisNone:# Center Dialog on appropriate screenframeGm=self.frameGeometry()ifself.screen<=0:qtscreen=QtGui.QGuiApplication.primaryScreen()else:qtscreen=self.screencenterPoint=qtscreen.availableGeometry().center()frameGm.moveCenter(centerPoint)self.move(frameGm.topLeft())else:self.move(self.pos[0],self.pos[1])QtWidgets.QDialog.show(self)self.raise_()self.activateWindow()ifself.inputFields:self.inputFields[0].setFocus()self.OK=FalseifQtWidgets.QDialog.exec(self):# == QtWidgets.QDialog.accepted:self.OK=Truereturnself.data
[docs]classDlgFromDict(Dlg):"""Creates a dialogue box that represents a dictionary of values. Any values changed by the user are change (in-place) by this dialogue box. Parameters ---------- dictionary : dict A dictionary defining the input fields (keys) and pre-filled values (values) for the user dialog title : str The title of the dialog window labels : dict A dictionary defining labels (values) to be displayed instead of key strings (keys) defined in `dictionary`. Not all keys in `dictionary` need to be contained in labels. fixed : list A list of keys for which the values shall be displayed in non-editable fields order : list A list of keys defining the display order of keys in `dictionary`. If not all keys in `dictionary`` are contained in `order`, those will appear in random order after all ordered keys. tip : list A dictionary assigning tooltips to the keys screen : int Screen number where the Dialog is displayed. If -1, the Dialog will be displayed on the primary screen. sortKeys : bool A boolean flag indicating that keys are to be sorted alphabetically. copyDict : bool If False, modify `dictionary` in-place. If True, a copy of the dictionary is created, and the altered version (after user interaction) can be retrieved from :attr:~`psychopy.gui.DlgFromDict.dictionary`. labels : dict A dictionary defining labels (dict values) to be displayed instead of key strings (dict keys) defined in `dictionary`. Not all keys in `dictionary´ need to be contained in labels. show : bool Whether to immediately display the dialog upon instantiation. If False, it can be displayed at a later time by calling its `show()` method. e.g.: :: info = {'Observer':'jwp', 'GratingOri':45, 'ExpVersion': 1.1, 'Group': ['Test', 'Control']} infoDlg = gui.DlgFromDict(dictionary=info, title='TestExperiment', fixed=['ExpVersion']) if infoDlg.OK: print(info) else: print('User Cancelled') In the code above, the contents of *info* will be updated to the values returned by the dialogue box. If the user cancels (rather than pressing OK), then the dictionary remains unchanged. If you want to check whether the user hit OK, then check whether DlgFromDict.OK equals True or False See GUI.py for a usage demo, including order and tip (tooltip). """def__init__(self,dictionary,title='',fixed=None,order=None,tip=None,screen=-1,sortKeys=True,copyDict=False,labels=None,show=True,alwaysOnTop=False):# Note: As of 2023.2.0, we do not allow sort_keys or copy_dictDlg.__init__(self,title,screen=screen,alwaysOnTop=alwaysOnTop)ifcopyDict:self.dictionary=dictionary.copy()else:self.dictionary=dictionary# initialise storage attributesself._labels=[]self._keys=[]# convert to a list of paramsparams=util.makeDisplayParams(self.dictionary,sortKeys=sortKeys,labels=labels,tooltips=tip,order=order,fixed=fixed)# make ctrlsforparaminparams:# if param is the readmore button, add it and continueifparam=="---":self.addReadmoreCtrl()continue# add asterisk to label if neededif"req"inparam['flags']and"*"notinparam['label']:param['label']+="*"# store attributes from this paramself._labels.append(param['label'])self._keys.append(param['key'])# make ctrlsif"hid"inparam['flags']:# don't add anything if it's hiddenpasselif"fix"inparam['flags']:self.addFixedField(param['key'],label=param['label'],initial=param['value'],tip=param['tip'])elifisinstance(param['value'],(list,tuple)):self.addField(param['key'],choices=param['value'],label=param['label'],tip=param['tip'],required="req"inparam['flags'])else:self.addField(param['key'],initial=param['value'],label=param['label'],tip=param['tip'],required="req"inparam['flags'])# validate so the required message is shown/hidden as appropriateself.validate()ifshow:self.show()
[docs]defshow(self):"""Display the dialog. """data=self.exec_()ifdataisnotNone:self.dictionary.update(data)returnself.dictionary
[docs]deffileSaveDlg(initFilePath="",initFileName="",prompt=_translate("Select file to save"),allowed=None):"""A simple dialogue allowing write access to the file system. (Useful in case you collect an hour of data and then try to save to a non-existent directory!!) :parameters: initFilePath: string default file path on which to open the dialog initFileName: string default file name, as suggested file prompt: string (default "Select file to open") can be set to custom prompts allowed: string a string to specify file filters. e.g. "Text files (\\*.txt) ;; Image files (\\*.bmp \\*.gif)" See https://www.riverbankcomputing.com/static/Docs/PyQt4/qfiledialog.html #getSaveFileName for further details If initFilePath or initFileName are empty or invalid then current path and empty names are used to start search. If user cancels the None is returned. """ifallowedisNone:allowed=("All files (*.*);;""txt (*.txt);;""pickled files (*.pickle *.pkl);;""shelved files (*.shelf)")ensureQtApp()fdir=os.path.join(initFilePath,initFileName)pathOut=QtWidgets.QFileDialog.getSaveFileName(parent=None,caption=prompt,directory=fdir,filter=allowed)iftype(pathOut)==tuple:# some versions(?) of PyQt return (files, filter)pathOut=pathOut[0]iflen(pathOut)==0:returnNonereturnstr(pathOut)orNone
[docs]deffileOpenDlg(tryFilePath="",tryFileName="",prompt=_translate("Select file to open"),allowed=None):"""A simple dialogue allowing read access to the file system. :parameters: tryFilePath: string default file path on which to open the dialog tryFileName: string default file name, as suggested file prompt: string (default "Select file to open") can be set to custom prompts allowed: string (available since v1.62.01) a string to specify file filters. e.g. "Text files (\\*.txt) ;; Image files (\\*.bmp \\*.gif)" See https://www.riverbankcomputing.com/static/Docs/PyQt4/qfiledialog.html #getOpenFileNames for further details If tryFilePath or tryFileName are empty or invalid then current path and empty names are used to start search. If user cancels, then None is returned. """ensureQtApp()ifallowedisNone:allowed=("All files (*.*);;""PsychoPy Data (*.psydat);;""txt (*.txt *.dlm *.csv);;""pickled files (*.pickle *.pkl);;""shelved files (*.shelf)")fdir=os.path.join(tryFilePath,tryFileName)filesToOpen=QtWidgets.QFileDialog.getOpenFileNames(parent=None,caption=prompt,directory=fdir,filter=allowed)iftype(filesToOpen)==tuple:# some versions(?) of PyQt return (files, filter)filesToOpen=filesToOpen[0]filesToOpen=[str(fpath)forfpathinfilesToOpenifos.path.exists(fpath)]iflen(filesToOpen)==0:returnNonereturnfilesToOpen