--- a/eric6/Graphics/ApplicationDiagramBuilder.py Sat May 01 14:27:38 2021 +0200 +++ b/eric6/Graphics/ApplicationDiagramBuilder.py Thu Jun 03 11:39:23 2021 +0200 @@ -10,7 +10,7 @@ import os import glob -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QInputDialog from E5Gui import E5MessageBox from E5Gui.E5ProgressDialog import E5ProgressDialog @@ -29,11 +29,15 @@ """ Constructor - @param dialog reference to the UML dialog (UMLDialog) - @param view reference to the view object (UMLGraphicsView) - @param project reference to the project object (Project) + @param dialog reference to the UML dialog + @type UMLDialog + @param view reference to the view object + @type UMLGraphicsView + @param project reference to the project object + @type Project @param noModules flag indicating, that no module names should be - shown (boolean) + shown + @type bool """ super().__init__(dialog, view, project) self.setObjectName("ApplicationDiagram") @@ -49,7 +53,8 @@ Private method to build a dictionary of modules contained in the application. - @return dictionary of modules contained in the application. + @return dictionary of modules contained in the application + @rtype dict """ import Utilities.ModuleParser extensions = ( @@ -88,27 +93,74 @@ progress.setValue(tot) progress.deleteLater() return moduleDict + + def __findApplicationRoot(self): + """ + Private method to find the application root path. + + @return application root path + @rtype str + """ + candidates = [] + path = self.project.getProjectPath() + init = os.path.join(path, "__init__.py") + if os.path.exists(init): + # project is a package + return path + else: + # check, if one of the top directories is a package + for entry in os.listdir(path): + if entry.startswith("."): + # ignore hidden files and directories + continue + + fullpath = os.path.join(path, entry) + if os.path.isdir(fullpath): + init = os.path.join(fullpath, "__init__.py") + if os.path.exists(init): + candidates.append(fullpath) + + if len(candidates) == 1: + return candidates[0] + elif len(candidates) > 1: + root, ok = QInputDialog.getItem( + None, + self.tr("Application Diagram"), + self.tr("Select the application directory:"), + sorted(candidates), + 0, True) + if ok: + return root + else: + E5MessageBox.warning( + None, + self.tr("Application Diagram"), + self.tr("""No application package could be detected.""" + """ Aborting...""")) + return None def buildDiagram(self): """ Public method to build the packages shapes of the diagram. """ - project = ( - os.path.splitdrive(self.project.getProjectPath())[1] - .replace(os.sep, '.')[1:] - ) + rpath = self.__findApplicationRoot() + if rpath is None: + # no root path detected + return + + root = os.path.splitdrive(rpath)[1].replace(os.sep, '.')[1:] + packages = {} - shapes = {} - p = 10 - y = 10 - maxHeight = 0 - sceneRect = self.umlView.sceneRect() + self.__shapes = {} modules = self.__buildModulesDict() # step 1: build a dictionary of packages for module in sorted(modules.keys()): - packageName, moduleName = module.rsplit(".", 1) + if "." in module: + packageName, moduleName = module.rsplit(".", 1) + else: + packageName, moduleName = "", module if packageName in packages: packages[packageName][0].append(moduleName) else: @@ -128,14 +180,14 @@ if n in modules: impLst.append(n) else: - n = "{0}.{1}".format(project, moduleImport) + n = "{0}.{1}".format(root, moduleImport) if n in modules: impLst.append(n) elif n in packages: n = "{0}.<<Dummy>>".format(n) impLst.append(n) else: - n = "{0}.{1}".format(project, moduleImport) + n = "{0}.{1}".format(root, moduleImport) if n in modules: impLst.append(n) for moduleImport in list(modules[module].from_imports.keys()): @@ -170,27 +222,30 @@ if n in modules: impLst.append(n) else: - n = "{0}.{1}".format(project, moduleImport) + n = "{0}.{1}".format(root, moduleImport) if n in modules: impLst.append(n) elif n in packages: n = "{0}.<<Dummy>>".format(n) impLst.append(n) else: - n = "{0}.{1}".format(project, moduleImport) + n = "{0}.{1}".format(root, moduleImport) if n in modules: impLst.append(n) for moduleImport in impLst: impPackage = moduleImport.rsplit(".", 1)[0] - if ( - impPackage not in packages[package][1] and - impPackage != package - ): - packages[package][1].append(impPackage) - + try: + if ( + impPackage not in packages[package][1] and + impPackage != package + ): + packages[package][1].append(impPackage) + except KeyError: + continue + for package in sorted(packages.keys()): if package: - relPackage = package.replace(project, '') + relPackage = package.replace(root, '') if relPackage and relPackage[0] == '.': relPackage = relPackage[1:] else: @@ -199,41 +254,36 @@ relPackage = self.tr("<<Others>>") shape = self.__addPackage( relPackage, packages[package][0], 0.0, 0.0) - shapeRect = shape.sceneBoundingRect() - shapes[package] = (shape, packages[package][1]) - pn = p + shapeRect.width() + 10 - maxHeight = max(maxHeight, shapeRect.height()) - if pn > sceneRect.width(): - p = 10 - y += maxHeight + 10 - maxHeight = shapeRect.height() - shape.setPos(p, y) - p += shapeRect.width() + 10 - else: - shape.setPos(p, y) - p = pn + self.__shapes[package] = (shape, packages[package][1]) - rect = self.umlView._getDiagramRect(10) - sceneRect = self.umlView.sceneRect() - if rect.width() > sceneRect.width(): - sceneRect.setWidth(rect.width()) - if rect.height() > sceneRect.height(): - sceneRect.setHeight(rect.height()) - self.umlView.setSceneSize(sceneRect.width(), sceneRect.height()) + # build a list of routes + nodes = [] + routes = [] + for module in self.__shapes: + nodes.append(module) + for rel in self.__shapes[module][1]: + route = (module, rel) + if route not in routes: + routes.append(route) - self.__createAssociations(shapes) + self.__arrangeNodes(nodes, routes[:]) + self.__createAssociations(routes) self.umlView.autoAdjustSceneSize(limit=True) - + def __addPackage(self, name, modules, x, y): """ Private method to add a package to the diagram. - @param name package name to be shown (string) + @param name package name to be shown + @type str @param modules list of module names contained in the package - (list of strings) - @param x x-coordinate (float) - @param y y-coordinate (float) - @return reference to the package item (PackageItem) + @type list of str + @param x x-coordinate + @type float + @param y y-coordinate + @type float + @return reference to the package item + @rtype PackageItem """ from .PackageItem import PackageItem, PackageModel modules.sort() @@ -242,27 +292,108 @@ colors=self.umlView.getDrawingColors()) pw.setId(self.umlView.getItemId()) return pw + + def __arrangeNodes(self, nodes, routes, whiteSpaceFactor=1.2): + """ + Private method to arrange the shapes on the canvas. - def __createAssociations(self, shapes): + The algorithm is borrowed from Boa Constructor. + + @param nodes list of nodes to arrange + @type list of str + @param routes list of routes + @type list of tuple of (str, str) + @param whiteSpaceFactor factor to increase whitespace between + items + @type float """ - Private method to generate the associations between the package shapes. + from . import GraphicsUtilities + generations = GraphicsUtilities.sort(nodes, routes) + + # calculate width and height of all elements + sizes = [] + for generation in generations: + sizes.append([]) + for child in generation: + sizes[-1].append( + self.__shapes[child][0].sceneBoundingRect()) + + # calculate total width and total height + width = 0 + height = 0 + widths = [] + heights = [] + for generation in sizes: + currentWidth = 0 + currentHeight = 0 + + for rect in generation: + if rect.height() > currentHeight: + currentHeight = rect.height() + currentWidth += rect.width() + + # update totals + if currentWidth > width: + width = currentWidth + height += currentHeight + + # store generation info + widths.append(currentWidth) + heights.append(currentHeight) - @param shapes list of shapes + # add in some whitespace + width *= whiteSpaceFactor + height = height * whiteSpaceFactor - 20 + verticalWhiteSpace = 40.0 + + sceneRect = self.umlView.sceneRect() + width += 50.0 + height += 50.0 + swidth = sceneRect.width() if width < sceneRect.width() else width + sheight = sceneRect.height() if height < sceneRect.height() else height + self.umlView.setSceneSize(swidth, sheight) + + # distribute each generation across the width and the + # generations across height + y = 10.0 + for currentWidth, currentHeight, generation in ( + zip(reversed(widths), reversed(heights), reversed(generations)) + ): + x = 10.0 + # whiteSpace is the space between any two elements + whiteSpace = ( + (width - currentWidth - 20) / + (len(generation) - 1.0 or 2.0) + ) + for name in generation: + shape = self.__shapes[name][0] + shape.setPos(x, y) + rect = shape.sceneBoundingRect() + x = x + rect.width() + whiteSpace + y = y + currentHeight + verticalWhiteSpace + + def __createAssociations(self, routes): + """ + Private method to generate the associations between the module shapes. + + @param routes list of associations + @type list of tuple of (str, str) """ from .AssociationItem import AssociationItem, AssociationType - for package in shapes: - for rel in shapes[package][1]: - assoc = AssociationItem( - shapes[package][0], shapes[rel][0], - AssociationType.IMPORTS, - colors=self.umlView.getDrawingColors()) - self.scene.addItem(assoc) + for route in routes: + assoc = AssociationItem( + self.__shapes[route[0]][0], + self.__shapes[route[1]][0], + AssociationType.IMPORTS, + colors=self.umlView.getDrawingColors()) + self.scene.addItem(assoc) def getPersistenceData(self): """ Public method to get a string for data to be persisted. - @return persisted data string (string) + @return persisted data string + @rtype str """ return "project={0}, no_modules={1}".format( self.project.getProjectFile(), self.noModules) @@ -271,9 +402,12 @@ """ Public method to parse persisted data. - @param version version of the data (string) - @param data persisted data to be parsed (string) - @return flag indicating success (boolean) + @param version version of the data + @type str + @param data persisted data to be parsed + @type str + @return flag indicating success + @rtype bool """ parts = data.split(", ") if ( @@ -300,3 +434,43 @@ self.initialize() return True + + def toDict(self): + """ + Public method to collect data to be persisted. + + @return dictionary containing data to be persisted + @rtype dict + """ + return { + "project_name": self.project.getProjectName(), + "no_modules": self.noModules, + } + + def fromDict(self, version, data): + """ + Public method to populate the class with data persisted by 'toDict()'. + + @param version version of the data + @type str + @param data dictionary containing the persisted data + @type dict + @return tuple containing a flag indicating success and an info + message in case the diagram belongs to a different project + @rtype tuple of (bool, str) + """ + try: + self.noModules = data["no_modules"] + + if data["project_name"] != self.project.getProjectName(): + msg = self.tr( + "<p>The diagram belongs to project <b>{0}</b>." + " Please open it and try again.</p>" + ).format(data["project_name"]) + return False, msg + except KeyError: + return False, "" + + self.initialize() + + return True, ""