diff -r 4e8b98454baa -r 800c432b34c8 eric7/Project/CreateDialogCodeDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/Project/CreateDialogCodeDialog.py Sat May 15 18:45:04 2021 +0200 @@ -0,0 +1,635 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to generate code for a Qt5 dialog. +""" + +import sys +import os +import json +import contextlib + +from PyQt5.QtCore import ( + pyqtSlot, Qt, QMetaObject, QRegularExpression, QSortFilterProxyModel, + QProcess, QProcessEnvironment +) +from PyQt5.QtGui import QStandardItemModel, QStandardItem, QBrush, QColor +from PyQt5.QtWidgets import QDialog, QDialogButtonBox + + +from E5Gui.E5Application import e5App +from E5Gui import E5MessageBox + +from .Ui_CreateDialogCodeDialog import Ui_CreateDialogCodeDialog +from .NewDialogClassDialog import NewDialogClassDialog + +from eric6config import getConfig + +import Preferences + + +pyqtSignatureRole = Qt.ItemDataRole.UserRole + 1 +pythonSignatureRole = Qt.ItemDataRole.UserRole + 2 +rubySignatureRole = Qt.ItemDataRole.UserRole + 3 +returnTypeRole = Qt.ItemDataRole.UserRole + 4 +parameterTypesListRole = Qt.ItemDataRole.UserRole + 5 +parameterNamesListRole = Qt.ItemDataRole.UserRole + 6 + + +class CreateDialogCodeDialog(QDialog, Ui_CreateDialogCodeDialog): + """ + Class implementing a dialog to generate code for a Qt5 dialog. + """ + DialogClasses = { + "QDialog", "QWidget", "QMainWindow", "QWizard", "QWizardPage", + "QDockWidget", "QFrame", "QGroupBox", "QScrollArea", "QMdiArea", + "QTabWidget", "QToolBox", "QStackedWidget" + } + Separator = 25 * "=" + + def __init__(self, formName, project, parent=None): + """ + Constructor + + @param formName name of the file containing the form (string) + @param project reference to the project object + @param parent parent widget if the dialog (QWidget) + """ + super().__init__(parent) + self.setupUi(self) + + self.okButton = self.buttonBox.button( + QDialogButtonBox.StandardButton.Ok) + + self.slotsView.header().hide() + + self.project = project + + self.formFile = formName + filename, ext = os.path.splitext(self.formFile) + self.srcFile = '{0}{1}'.format( + filename, self.project.getDefaultSourceExtension()) + + self.slotsModel = QStandardItemModel() + self.proxyModel = QSortFilterProxyModel() + self.proxyModel.setDynamicSortFilter(True) + self.proxyModel.setSourceModel(self.slotsModel) + self.slotsView.setModel(self.proxyModel) + + # initialize some member variables + self.__initError = False + self.__module = None + + packagesRoot = self.project.getUicParameter("PackagesRoot") + if packagesRoot: + self.packagesPath = os.path.join(self.project.getProjectPath(), + packagesRoot) + else: + self.packagesPath = self.project.getProjectPath() + + if os.path.exists(self.srcFile): + vm = e5App().getObject("ViewManager") + ed = vm.getOpenEditor(self.srcFile) + if ed and not vm.checkDirty(ed): + self.__initError = True + return + + with contextlib.suppress(ImportError): + splitExt = os.path.splitext(self.srcFile) + exts = [splitExt[1]] if len(splitExt) == 2 else None + from Utilities import ModuleParser + self.__module = ModuleParser.readModule( + self.srcFile, extensions=exts, caching=False) + + if self.__module is not None: + self.filenameEdit.setText(self.srcFile) + + classesList = [] + vagueClassesList = [] + for cls in list(self.__module.classes.values()): + if not set(cls.super).isdisjoint( + CreateDialogCodeDialog.DialogClasses): + classesList.append(cls.name) + else: + vagueClassesList.append(cls.name) + classesList.sort() + self.classNameCombo.addItems(classesList) + if vagueClassesList: + if classesList: + self.classNameCombo.addItem( + CreateDialogCodeDialog.Separator) + self.classNameCombo.addItems(sorted(vagueClassesList)) + + if ( + os.path.exists(self.srcFile) and + self.__module is not None and + self.classNameCombo.count() == 0 + ): + self.__initError = True + E5MessageBox.critical( + self, + self.tr("Create Dialog Code"), + self.tr( + """The file <b>{0}</b> exists but does not contain""" + """ any classes.""").format(self.srcFile)) + + self.okButton.setEnabled(self.classNameCombo.count() > 0) + + self.__updateSlotsModel() + + def initError(self): + """ + Public method to determine, if there was an initialzation error. + + @return flag indicating an initialzation error (boolean) + """ + return self.__initError + + def __runUicLoadUi(self, command): + """ + Private method to run the UicLoadUi.py script with the given command + and return the output. + + @param command uic command to be run + @type str + @return tuple of process output and error flag + @rtype tuple of (str, bool) + """ + venvManager = e5App().getObject("VirtualEnvManager") + projectType = self.project.getProjectType() + + venvName = self.project.getDebugProperty("VIRTUALENV") + if not venvName: + # no project specific environment, try a type specific one + if projectType in ("PyQt5", "E6Plugin", "PySide2"): + venvName = Preferences.getQt("PyQtVenvName") + elif projectType in ("PyQt6", "PySide6"): + venvName = Preferences.getQt("PyQt6VenvName") + interpreter = venvManager.getVirtualenvInterpreter(venvName) + execPath = venvManager.getVirtualenvExecPath(venvName) + + if not interpreter: + interpreter = sys.executable + + env = QProcessEnvironment.systemEnvironment() + if execPath: + if env.contains("PATH"): + env.insert( + "PATH", os.pathsep.join([execPath, env.value("PATH")]) + ) + else: + env.insert("PATH", execPath) + + if projectType in ("PyQt5", "E6Plugin", "PySide2"): + loadUi = os.path.join(os.path.dirname(__file__), "UicLoadUi5.py") + elif projectType in ("PyQt6", "PySide6"): + loadUi = os.path.join(os.path.dirname(__file__), "UicLoadUi6.py") + args = [ + loadUi, + command, + self.formFile, + self.packagesPath, + ] + + uicText = "" + ok = False + + proc = QProcess() + proc.setWorkingDirectory(self.packagesPath) + proc.setProcessEnvironment(env) + proc.start(interpreter, args) + started = proc.waitForStarted(5000) + finished = proc.waitForFinished(30000) + if started and finished: + output = proc.readAllStandardOutput() + outText = str(output, "utf-8", "replace") + if proc.exitCode() == 0: + ok = True + uicText = outText.strip() + else: + E5MessageBox.critical( + self, + self.tr("uic error"), + self.tr( + """<p>There was an error loading the form <b>{0}</b>""" + """.</p><p>{1}</p>""").format( + self.formFile, outText) + ) + else: + E5MessageBox.critical( + self, + self.tr("uic error"), + self.tr( + """<p>The project specific Python interpreter <b>{0}</b>""" + """ could not be started or did not finish within 30""" + """ seconds.</p>""").format(interpreter) + ) + + return uicText, ok + + def __objectName(self): + """ + Private method to get the object name of a form. + + @return object name + @rtype str + """ + objectName = "" + + output, ok = self.__runUicLoadUi("object_name") + if ok and output: + objectName = output + + return objectName + + def __className(self): + """ + Private method to get the class name of a form. + + @return class name + @rtype str + """ + className = "" + + output, ok = self.__runUicLoadUi("class_name") + if ok and output: + className = output + + return className + + def __signatures(self): + """ + Private slot to get the signatures. + + @return list of signatures (list of strings) + """ + if self.__module is None: + return [] + + signatures = [] + clsName = self.classNameCombo.currentText() + if clsName: + cls = self.__module.classes[clsName] + for meth in list(cls.methods.values()): + if meth.name.startswith("on_"): + if meth.pyqtSignature is not None: + sig = ", ".join( + [bytes(QMetaObject.normalizedType(t)).decode() + for t in meth.pyqtSignature.split(",")]) + signatures.append("{0}({1})".format(meth.name, sig)) + else: + signatures.append(meth.name) + return signatures + + def __mapType(self, type_): + """ + Private method to map a type as reported by Qt's meta object to the + correct Python type. + + @param type_ type as reported by Qt (QByteArray) + @return mapped Python type (string) + """ + mapped = bytes(type_).decode() + + # I. always check for * + mapped = mapped.replace("*", "") + + # 1. check for const + mapped = mapped.replace("const ", "") + + # 2. replace QString and QStringList + mapped = ( + mapped + .replace("QStringList", "list") + .replace("QString", "str") + ) + + # 3. replace double by float + mapped = mapped.replace("double", "float") + + return mapped + + def __updateSlotsModel(self): + """ + Private slot to update the slots tree display. + """ + self.filterEdit.clear() + + output, ok = self.__runUicLoadUi("signatures") + if ok and output: + objectsList = json.loads(output.strip()) + + signatureList = self.__signatures() + + self.slotsModel.clear() + self.slotsModel.setHorizontalHeaderLabels([""]) + for objectDict in objectsList: + itm = QStandardItem("{0} ({1})".format( + objectDict["name"], + objectDict["class_name"])) + self.slotsModel.appendRow(itm) + for methodDict in objectDict["methods"]: + itm2 = QStandardItem(methodDict["signature"]) + itm.appendRow(itm2) + + if ( + self.__module is not None and + (methodDict["methods"][0] in signatureList or + methodDict["methods"][1] in signatureList) + ): + itm2.setFlags( + Qt.ItemFlags(Qt.ItemFlag.ItemIsEnabled)) + itm2.setCheckState(Qt.CheckState.Checked) + if e5App().usesDarkPalette(): + itm2.setForeground(QBrush(QColor("#75bfff"))) + else: + itm2.setForeground(QBrush(Qt.GlobalColor.blue)) + continue + + itm2.setData(methodDict["pyqt_signature"], + pyqtSignatureRole) + itm2.setData(methodDict["python_signature"], + pythonSignatureRole) + itm2.setData(methodDict["return_type"], + returnTypeRole) + itm2.setData(methodDict["parameter_types"], + parameterTypesListRole) + itm2.setData(methodDict["parameter_names"], + parameterNamesListRole) + + itm2.setFlags(Qt.ItemFlags( + Qt.ItemFlag.ItemIsUserCheckable | + Qt.ItemFlag.ItemIsEnabled | + Qt.ItemFlag.ItemIsSelectable) + ) + itm2.setCheckState(Qt.CheckState.Unchecked) + + self.slotsView.sortByColumn(0, Qt.SortOrder.AscendingOrder) + + def __generateCode(self): + """ + Private slot to generate the code as requested by the user. + """ + if ( + self.filenameEdit.text().endswith(".rb") or + self.project.getProjectLanguage() == "Ruby" + ): + # Ruby code generation is not supported + pass + else: + # assume Python (our global default) + self.__generatePythonCode() + + def __generatePythonCode(self): + """ + Private slot to generate Python code as requested by the user. + """ + if self.project.getProjectLanguage() != "Python3": + E5MessageBox.critical( + self, + self.tr("Code Generation"), + self.tr( + """<p>Code generation for project language""" + """ "{0}" is not supported.</p>""") + .format(self.project.getProjectLanguage())) + return + + # init some variables + sourceImpl = [] + appendAtIndex = -1 + indentStr = " " + slotsCode = [] + + if self.__module is None: + # new file + try: + if self.project.getProjectType() == "PySide2": + tmplName = os.path.join( + getConfig('ericCodeTemplatesDir'), + "impl_pyside2.py.tmpl") + elif self.project.getProjectType() == "PySide6": + tmplName = os.path.join( + getConfig('ericCodeTemplatesDir'), + "impl_pyside6.py.tmpl") + elif self.project.getProjectType() in [ + "PyQt5", "E6Plugin"]: + tmplName = os.path.join( + getConfig('ericCodeTemplatesDir'), + "impl_pyqt5.py.tmpl") + elif self.project.getProjectType() == "PyQt6": + tmplName = os.path.join( + getConfig('ericCodeTemplatesDir'), + "impl_pyqt6.py.tmpl") + else: + E5MessageBox.critical( + self, + self.tr("Code Generation"), + self.tr( + """<p>No code template file available for""" + """ project type "{0}".</p>""") + .format(self.project.getProjectType())) + return + with open(tmplName, 'r', encoding="utf-8") as tmplFile: + template = tmplFile.read() + except OSError as why: + E5MessageBox.critical( + self, + self.tr("Code Generation"), + self.tr( + """<p>Could not open the code template file""" + """ "{0}".</p><p>Reason: {1}</p>""") + .format(tmplName, str(why))) + return + + objName = self.__objectName() + if objName: + template = ( + template + .replace( + "$FORMFILE$", + os.path.splitext(os.path.basename(self.formFile))[0]) + .replace("$FORMCLASS$", objName) + .replace("$CLASSNAME$", self.classNameCombo.currentText()) + .replace("$SUPERCLASS$", self.__className()) + ) + + sourceImpl = template.splitlines(True) + appendAtIndex = -1 + + # determine indent string + for line in sourceImpl: + if line.lstrip().startswith("def __init__"): + indentStr = line.replace(line.lstrip(), "") + break + else: + # extend existing file + try: + with open(self.srcFile, 'r', encoding="utf-8") as srcFile: + sourceImpl = srcFile.readlines() + if not sourceImpl[-1].endswith("\n"): + sourceImpl[-1] = "{0}{1}".format(sourceImpl[-1], "\n") + except OSError as why: + E5MessageBox.critical( + self, + self.tr("Code Generation"), + self.tr( + """<p>Could not open the source file "{0}".</p>""" + """<p>Reason: {1}</p>""") + .format(self.srcFile, str(why))) + return + + cls = self.__module.classes[self.classNameCombo.currentText()] + if cls.endlineno == len(sourceImpl) or cls.endlineno == -1: + appendAtIndex = -1 + # delete empty lines at end + while not sourceImpl[-1].strip(): + del sourceImpl[-1] + else: + appendAtIndex = cls.endlineno - 1 + while not sourceImpl[appendAtIndex].strip(): + appendAtIndex -= 1 + appendAtIndex += 1 + + # determine indent string + for line in sourceImpl[cls.lineno:cls.endlineno + 1]: + if line.lstrip().startswith("def __init__"): + indentStr = line.replace(line.lstrip(), "") + break + + # do the coding stuff + pyqtSignatureFormat = ( + '@Slot({0})' + if self.project.getProjectType() in ("PySide2", "PySide6") else + '@pyqtSlot({0})' + ) + for row in range(self.slotsModel.rowCount()): + topItem = self.slotsModel.item(row) + for childRow in range(topItem.rowCount()): + child = topItem.child(childRow) + if ( + child.checkState() and + child.flags() & Qt.ItemFlags( + Qt.ItemFlag.ItemIsUserCheckable) + ): + slotsCode.append('{0}\n'.format(indentStr)) + slotsCode.append('{0}{1}\n'.format( + indentStr, + pyqtSignatureFormat.format( + child.data(pyqtSignatureRole)))) + slotsCode.append('{0}def {1}:\n'.format( + indentStr, child.data(pythonSignatureRole))) + indentStr2 = indentStr * 2 + slotsCode.append('{0}"""\n'.format(indentStr2)) + slotsCode.append( + '{0}Slot documentation goes here.\n'.format( + indentStr2)) + if ( + child.data(returnTypeRole) or + child.data(parameterTypesListRole) + ): + slotsCode.append('{0}\n'.format(indentStr2)) + if child.data(parameterTypesListRole): + for name, type_ in zip( + child.data(parameterNamesListRole), + child.data(parameterTypesListRole)): + slotsCode.append( + '{0}@param {1} DESCRIPTION\n'.format( + indentStr2, name)) + slotsCode.append('{0}@type {1}\n'.format( + indentStr2, type_)) + if child.data(returnTypeRole): + slotsCode.append( + '{0}@returns DESCRIPTION\n'.format( + indentStr2)) + slotsCode.append('{0}@rtype {1}\n'.format( + indentStr2, child.data(returnTypeRole))) + slotsCode.append('{0}"""\n'.format(indentStr2)) + slotsCode.append('{0}# {1}: not implemented yet\n'.format( + indentStr2, "TODO")) + slotsCode.append('{0}raise NotImplementedError\n'.format( + indentStr2)) + + if appendAtIndex == -1: + sourceImpl.extend(slotsCode) + else: + sourceImpl[appendAtIndex:appendAtIndex] = slotsCode + + # write the new code + newline = (None if self.project.useSystemEol() + else self.project.getEolString()) + fn = self.filenameEdit.text() + try: + with open(fn, 'w', encoding="utf-8", newline=newline) as srcFile: + srcFile.write("".join(sourceImpl)) + except OSError as why: + E5MessageBox.critical( + self, + self.tr("Code Generation"), + self.tr("""<p>Could not write the source file "{0}".</p>""" + """<p>Reason: {1}</p>""") + .format(fn, str(why))) + return + + self.project.appendFile(fn) + + @pyqtSlot(int) + def on_classNameCombo_activated(self, index): + """ + Private slot to handle the activated signal of the classname combo. + + @param index index of the activated item (integer) + """ + if (self.classNameCombo.currentText() == + CreateDialogCodeDialog.Separator): + self.okButton.setEnabled(False) + self.filterEdit.clear() + self.slotsModel.clear() + self.slotsModel.setHorizontalHeaderLabels([""]) + else: + self.okButton.setEnabled(True) + self.__updateSlotsModel() + + def on_filterEdit_textChanged(self, text): + """ + Private slot called, when thext of the filter edit has changed. + + @param text changed text (string) + """ + rx = QRegularExpression( + text, + QRegularExpression.PatternOption.CaseInsensitiveOption) + self.proxyModel.setFilterRegularExpression(rx) + + @pyqtSlot() + def on_newButton_clicked(self): + """ + Private slot called to enter the data for a new dialog class. + """ + path, file = os.path.split(self.srcFile) + objName = self.__objectName() + if objName: + dlg = NewDialogClassDialog(objName, file, path, self) + if dlg.exec() == QDialog.DialogCode.Accepted: + className, fileName = dlg.getData() + + self.classNameCombo.clear() + self.classNameCombo.addItem(className) + self.srcFile = fileName + self.filenameEdit.setText(self.srcFile) + self.__module = None + + self.okButton.setEnabled(self.classNameCombo.count() > 0) + + def on_buttonBox_clicked(self, button): + """ + Private slot to handle the buttonBox clicked signal. + + @param button reference to the button that was clicked + (QAbstractButton) + """ + if button == self.okButton: + self.__generateCode() + self.accept()