|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog showing UML like diagrams. |
|
8 """ |
|
9 |
|
10 import enum |
|
11 import json |
|
12 |
|
13 from PyQt5.QtCore import pyqtSlot, Qt, QFileInfo, QCoreApplication |
|
14 from PyQt5.QtWidgets import QAction, QToolBar, QGraphicsScene |
|
15 |
|
16 from E5Gui import E5MessageBox, E5FileDialog |
|
17 from E5Gui.E5MainWindow import E5MainWindow |
|
18 |
|
19 import UI.Config |
|
20 import UI.PixmapCache |
|
21 |
|
22 |
|
23 class UMLDialogType(enum.Enum): |
|
24 """ |
|
25 Class defining the UML dialog types. |
|
26 """ |
|
27 CLASS_DIAGRAM = 0 |
|
28 PACKAGE_DIAGRAM = 1 |
|
29 IMPORTS_DIAGRAM = 2 |
|
30 APPLICATION_DIAGRAM = 3 |
|
31 NO_DIAGRAM = 255 |
|
32 |
|
33 |
|
34 class UMLDialog(E5MainWindow): |
|
35 """ |
|
36 Class implementing a dialog showing UML like diagrams. |
|
37 """ |
|
38 FileVersions = ("1.0", ) |
|
39 JsonFileVersions = ("1.0", ) |
|
40 |
|
41 UMLDialogType2String = { |
|
42 UMLDialogType.CLASS_DIAGRAM: |
|
43 QCoreApplication.translate("UMLDialog", "Class Diagram"), |
|
44 UMLDialogType.PACKAGE_DIAGRAM: |
|
45 QCoreApplication.translate("UMLDialog", "Package Diagram"), |
|
46 UMLDialogType.IMPORTS_DIAGRAM: |
|
47 QCoreApplication.translate("UMLDialog", "Imports Diagram"), |
|
48 UMLDialogType.APPLICATION_DIAGRAM: |
|
49 QCoreApplication.translate("UMLDialog", "Application Diagram"), |
|
50 } |
|
51 |
|
52 def __init__(self, diagramType, project, path="", parent=None, |
|
53 initBuilder=True, **kwargs): |
|
54 """ |
|
55 Constructor |
|
56 |
|
57 @param diagramType type of the diagram |
|
58 @type UMLDialogType |
|
59 @param project reference to the project object |
|
60 @type Project |
|
61 @param path file or directory path to build the diagram from |
|
62 @type str |
|
63 @param parent parent widget of the dialog |
|
64 @type QWidget |
|
65 @param initBuilder flag indicating to initialize the diagram |
|
66 builder |
|
67 @type bool |
|
68 @keyparam kwargs diagram specific data |
|
69 @type dict |
|
70 """ |
|
71 super().__init__(parent) |
|
72 self.setObjectName("UMLDialog") |
|
73 |
|
74 self.__project = project |
|
75 self.__diagramType = diagramType |
|
76 |
|
77 from .UMLGraphicsView import UMLGraphicsView |
|
78 self.scene = QGraphicsScene(0.0, 0.0, 800.0, 600.0) |
|
79 self.umlView = UMLGraphicsView(self.scene, parent=self) |
|
80 self.builder = self.__diagramBuilder( |
|
81 self.__diagramType, path, **kwargs) |
|
82 if self.builder and initBuilder: |
|
83 self.builder.initialize() |
|
84 |
|
85 self.__fileName = "" |
|
86 |
|
87 self.__initActions() |
|
88 self.__initToolBars() |
|
89 |
|
90 self.setCentralWidget(self.umlView) |
|
91 |
|
92 self.umlView.relayout.connect(self.__relayout) |
|
93 |
|
94 self.setWindowTitle(self.__getDiagramTitel(self.__diagramType)) |
|
95 |
|
96 def __getDiagramTitel(self, diagramType): |
|
97 """ |
|
98 Private method to get a textual description for the diagram type. |
|
99 |
|
100 @param diagramType diagram type string |
|
101 @type str |
|
102 @return titel of the diagram |
|
103 @rtype str |
|
104 """ |
|
105 return UMLDialog.UMLDialogType2String.get( |
|
106 diagramType, self.tr("Illegal Diagram Type") |
|
107 ) |
|
108 |
|
109 def __initActions(self): |
|
110 """ |
|
111 Private slot to initialize the actions. |
|
112 """ |
|
113 self.closeAct = QAction( |
|
114 UI.PixmapCache.getIcon("close"), |
|
115 self.tr("Close"), self) |
|
116 self.closeAct.triggered.connect(self.close) |
|
117 |
|
118 self.openAct = QAction( |
|
119 UI.PixmapCache.getIcon("open"), |
|
120 self.tr("Load"), self) |
|
121 self.openAct.triggered.connect(self.load) |
|
122 |
|
123 self.saveAct = QAction( |
|
124 UI.PixmapCache.getIcon("fileSave"), |
|
125 self.tr("Save"), self) |
|
126 self.saveAct.triggered.connect(self.__save) |
|
127 |
|
128 self.saveAsAct = QAction( |
|
129 UI.PixmapCache.getIcon("fileSaveAs"), |
|
130 self.tr("Save As..."), self) |
|
131 self.saveAsAct.triggered.connect(self.__saveAs) |
|
132 |
|
133 self.saveImageAct = QAction( |
|
134 UI.PixmapCache.getIcon("fileSavePixmap"), |
|
135 self.tr("Save as Image"), self) |
|
136 self.saveImageAct.triggered.connect(self.umlView.saveImage) |
|
137 |
|
138 self.printAct = QAction( |
|
139 UI.PixmapCache.getIcon("print"), |
|
140 self.tr("Print"), self) |
|
141 self.printAct.triggered.connect(self.umlView.printDiagram) |
|
142 |
|
143 self.printPreviewAct = QAction( |
|
144 UI.PixmapCache.getIcon("printPreview"), |
|
145 self.tr("Print Preview"), self) |
|
146 self.printPreviewAct.triggered.connect( |
|
147 self.umlView.printPreviewDiagram) |
|
148 |
|
149 def __initToolBars(self): |
|
150 """ |
|
151 Private slot to initialize the toolbars. |
|
152 """ |
|
153 self.windowToolBar = QToolBar(self.tr("Window"), self) |
|
154 self.windowToolBar.setIconSize(UI.Config.ToolBarIconSize) |
|
155 self.windowToolBar.addAction(self.closeAct) |
|
156 |
|
157 self.fileToolBar = QToolBar(self.tr("File"), self) |
|
158 self.fileToolBar.setIconSize(UI.Config.ToolBarIconSize) |
|
159 self.fileToolBar.addAction(self.openAct) |
|
160 self.fileToolBar.addSeparator() |
|
161 self.fileToolBar.addAction(self.saveAct) |
|
162 self.fileToolBar.addAction(self.saveAsAct) |
|
163 self.fileToolBar.addAction(self.saveImageAct) |
|
164 self.fileToolBar.addSeparator() |
|
165 self.fileToolBar.addAction(self.printPreviewAct) |
|
166 self.fileToolBar.addAction(self.printAct) |
|
167 |
|
168 self.umlToolBar = self.umlView.initToolBar() |
|
169 |
|
170 self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.fileToolBar) |
|
171 self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.windowToolBar) |
|
172 self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.umlToolBar) |
|
173 |
|
174 def show(self, fromFile=False): |
|
175 """ |
|
176 Public method to show the dialog. |
|
177 |
|
178 @param fromFile flag indicating, that the diagram was loaded |
|
179 from file |
|
180 @type bool |
|
181 """ |
|
182 if not fromFile and self.builder: |
|
183 self.builder.buildDiagram() |
|
184 super().show() |
|
185 |
|
186 def __relayout(self): |
|
187 """ |
|
188 Private method to re-layout the diagram. |
|
189 """ |
|
190 if self.builder: |
|
191 self.builder.buildDiagram() |
|
192 |
|
193 def __diagramBuilder(self, diagramType, path, **kwargs): |
|
194 """ |
|
195 Private method to instantiate a diagram builder object. |
|
196 |
|
197 @param diagramType type of the diagram |
|
198 @type UMLDialogType |
|
199 @param path file or directory path to build the diagram from |
|
200 @type str |
|
201 @keyparam kwargs diagram specific data |
|
202 @type dict |
|
203 @return reference to the instantiated diagram builder |
|
204 @rtype UMLDiagramBuilder |
|
205 """ |
|
206 if diagramType == UMLDialogType.CLASS_DIAGRAM: |
|
207 from .UMLClassDiagramBuilder import UMLClassDiagramBuilder |
|
208 return UMLClassDiagramBuilder( |
|
209 self, self.umlView, self.__project, path, **kwargs) |
|
210 elif diagramType == UMLDialogType.PACKAGE_DIAGRAM: |
|
211 from .PackageDiagramBuilder import PackageDiagramBuilder |
|
212 return PackageDiagramBuilder( |
|
213 self, self.umlView, self.__project, path, **kwargs) |
|
214 elif diagramType == UMLDialogType.IMPORTS_DIAGRAM: |
|
215 from .ImportsDiagramBuilder import ImportsDiagramBuilder |
|
216 return ImportsDiagramBuilder( |
|
217 self, self.umlView, self.__project, path, **kwargs) |
|
218 elif diagramType == UMLDialogType.APPLICATION_DIAGRAM: |
|
219 from .ApplicationDiagramBuilder import ApplicationDiagramBuilder |
|
220 return ApplicationDiagramBuilder( |
|
221 self, self.umlView, self.__project, **kwargs) |
|
222 else: |
|
223 return None |
|
224 |
|
225 def __save(self): |
|
226 """ |
|
227 Private slot to save the diagram with the current name. |
|
228 """ |
|
229 self.__saveAs(self.__fileName) |
|
230 |
|
231 @pyqtSlot() |
|
232 def __saveAs(self, filename=""): |
|
233 """ |
|
234 Private slot to save the diagram. |
|
235 |
|
236 @param filename name of the file to write to |
|
237 @type str |
|
238 """ |
|
239 if not filename: |
|
240 fname, selectedFilter = E5FileDialog.getSaveFileNameAndFilter( |
|
241 self, |
|
242 self.tr("Save Diagram"), |
|
243 "", |
|
244 self.tr("Eric Graphics File (*.egj);;" |
|
245 "Eric Text Graphics File (*.e5g);;" |
|
246 "All Files (*)"), |
|
247 "", |
|
248 E5FileDialog.Options(E5FileDialog.DontConfirmOverwrite)) |
|
249 if not fname: |
|
250 return |
|
251 ext = QFileInfo(fname).suffix() |
|
252 if not ext: |
|
253 ex = selectedFilter.split("(*")[1].split(")")[0] |
|
254 if ex: |
|
255 fname += ex |
|
256 if QFileInfo(fname).exists(): |
|
257 res = E5MessageBox.yesNo( |
|
258 self, |
|
259 self.tr("Save Diagram"), |
|
260 self.tr("<p>The file <b>{0}</b> already exists." |
|
261 " Overwrite it?</p>").format(fname), |
|
262 icon=E5MessageBox.Warning) |
|
263 if not res: |
|
264 return |
|
265 filename = fname |
|
266 |
|
267 res = ( |
|
268 self.__writeLineBasedGraphicsFile(filename) |
|
269 if filename.endswith(".e5g") else |
|
270 # JSON format is the default |
|
271 self.__writeJsonGraphicsFile(filename) |
|
272 ) |
|
273 |
|
274 if res: |
|
275 # save the file name only in case of success |
|
276 self.__fileName = filename |
|
277 |
|
278 # TODO: eric7: delete the current one |
|
279 def load(self, filename=""): |
|
280 """ |
|
281 Public method to load a diagram from a file. |
|
282 |
|
283 @param filename name of the file to be loaded |
|
284 @type str |
|
285 @return flag indicating success |
|
286 @rtype bool |
|
287 """ |
|
288 if not filename: |
|
289 filename = E5FileDialog.getOpenFileName( |
|
290 self, |
|
291 self.tr("Load Diagram"), |
|
292 "", |
|
293 self.tr("Eric Graphics File (*.egj);;" |
|
294 "Eric Text Graphics File (*.e5g);;" |
|
295 "All Files (*)")) |
|
296 if not filename: |
|
297 # Canceled by user |
|
298 return False |
|
299 |
|
300 return ( |
|
301 self.__readLineBasedGraphicsFile(filename) |
|
302 if filename.endswith(".e5g") else |
|
303 # JSON format is the default |
|
304 self.__readJsonGraphicsFile(filename) |
|
305 ) |
|
306 |
|
307 ####################################################################### |
|
308 ## Methods to read and write eric graphics files of the old line |
|
309 ## based file format. |
|
310 ####################################################################### |
|
311 |
|
312 def __readLineBasedGraphicsFile(self, filename): |
|
313 """ |
|
314 Private method to read an eric graphics file using the old line |
|
315 based file format. |
|
316 |
|
317 @param filename name of the file to be read |
|
318 @type str |
|
319 @return flag indicating success |
|
320 @rtype bool |
|
321 """ |
|
322 try: |
|
323 with open(filename, "r", encoding="utf-8") as f: |
|
324 data = f.read() |
|
325 except OSError as err: |
|
326 E5MessageBox.critical( |
|
327 self, |
|
328 self.tr("Load Diagram"), |
|
329 self.tr( |
|
330 """<p>The file <b>{0}</b> could not be read.</p>""" |
|
331 """<p>Reason: {1}</p>""").format(filename, str(err))) |
|
332 return False |
|
333 |
|
334 lines = data.splitlines() |
|
335 if len(lines) < 3: |
|
336 self.__showInvalidDataMessage(filename) |
|
337 return False |
|
338 |
|
339 try: |
|
340 # step 1: check version |
|
341 linenum = 0 |
|
342 key, value = lines[linenum].split(": ", 1) |
|
343 if ( |
|
344 key.strip() != "version" or |
|
345 value.strip() not in UMLDialog.FileVersions |
|
346 ): |
|
347 self.__showInvalidDataMessage(filename, linenum) |
|
348 return False |
|
349 else: |
|
350 version = value |
|
351 |
|
352 # step 2: extract diagram type |
|
353 linenum += 1 |
|
354 key, value = lines[linenum].split(": ", 1) |
|
355 if key.strip() != "diagram_type": |
|
356 self.__showInvalidDataMessage(filename, linenum) |
|
357 return False |
|
358 try: |
|
359 diagramType = value.strip().split(None, 1)[0] |
|
360 self.__diagramType = UMLDialogType(int(diagramType)) |
|
361 except ValueError: |
|
362 self.__showInvalidDataMessage(filename, linenum) |
|
363 return False |
|
364 self.scene.clear() |
|
365 self.builder = self.__diagramBuilder(self.__diagramType, "") |
|
366 |
|
367 # step 3: extract scene size |
|
368 linenum += 1 |
|
369 key, value = lines[linenum].split(": ", 1) |
|
370 if key.strip() != "scene_size": |
|
371 self.__showInvalidDataMessage(filename, linenum) |
|
372 return False |
|
373 try: |
|
374 width, height = [float(v.strip()) for v in value.split(";")] |
|
375 except ValueError: |
|
376 self.__showInvalidDataMessage(filename, linenum) |
|
377 return False |
|
378 self.umlView.setSceneSize(width, height) |
|
379 |
|
380 # step 4: extract builder data if available |
|
381 linenum += 1 |
|
382 key, value = lines[linenum].split(": ", 1) |
|
383 if key.strip() == "builder_data": |
|
384 ok = self.builder.parsePersistenceData(version, value) |
|
385 if not ok: |
|
386 self.__showInvalidDataMessage(filename, linenum) |
|
387 return False |
|
388 linenum += 1 |
|
389 |
|
390 # step 5: extract the graphics items |
|
391 ok, vlinenum = self.umlView.parsePersistenceData( |
|
392 version, lines[linenum:]) |
|
393 if not ok: |
|
394 self.__showInvalidDataMessage(filename, linenum + vlinenum) |
|
395 return False |
|
396 |
|
397 except IndexError: |
|
398 self.__showInvalidDataMessage(filename) |
|
399 return False |
|
400 |
|
401 # everything worked fine, so remember the file name and set the |
|
402 # window title |
|
403 self.setWindowTitle(self.__getDiagramTitel(self.__diagramType)) |
|
404 self.__fileName = filename |
|
405 |
|
406 return True |
|
407 |
|
408 def __writeLineBasedGraphicsFile(self, filename): |
|
409 """ |
|
410 Private method to write an eric graphics file using the old line |
|
411 based file format. |
|
412 |
|
413 @param filename name of the file to write to |
|
414 @type str |
|
415 @return flag indicating a successful write |
|
416 @rtype bool |
|
417 """ |
|
418 lines = [ |
|
419 "version: 1.0", |
|
420 "diagram_type: {0} ({1})".format( |
|
421 self.__diagramType.value, |
|
422 self.__getDiagramTitel(self.__diagramType)), |
|
423 "scene_size: {0};{1}".format(self.scene.width(), |
|
424 self.scene.height()), |
|
425 ] |
|
426 persistenceData = self.builder.getPersistenceData() |
|
427 if persistenceData: |
|
428 lines.append("builder_data: {0}".format(persistenceData)) |
|
429 lines.extend(self.umlView.getPersistenceData()) |
|
430 |
|
431 try: |
|
432 with open(filename, "w", encoding="utf-8") as f: |
|
433 f.write("\n".join(lines)) |
|
434 return True |
|
435 except OSError as err: |
|
436 E5MessageBox.critical( |
|
437 self, |
|
438 self.tr("Save Diagram"), |
|
439 self.tr( |
|
440 """<p>The file <b>{0}</b> could not be saved.</p>""" |
|
441 """<p>Reason: {1}</p>""").format(filename, str(err))) |
|
442 return False |
|
443 |
|
444 def __showInvalidDataMessage(self, filename, linenum=-1): |
|
445 """ |
|
446 Private slot to show a message dialog indicating an invalid data file. |
|
447 |
|
448 @param filename name of the file containing the invalid data |
|
449 @type str |
|
450 @param linenum number of the invalid line |
|
451 @type int |
|
452 """ |
|
453 msg = ( |
|
454 self.tr("""<p>The file <b>{0}</b> does not contain""" |
|
455 """ valid data.</p>""").format(filename) |
|
456 if linenum < 0 else |
|
457 self.tr("""<p>The file <b>{0}</b> does not contain""" |
|
458 """ valid data.</p><p>Invalid line: {1}</p>""" |
|
459 ).format(filename, linenum + 1) |
|
460 ) |
|
461 E5MessageBox.critical(self, self.tr("Load Diagram"), msg) |
|
462 |
|
463 ####################################################################### |
|
464 ## Methods to read and write eric graphics files of the JSON based |
|
465 ## file format. |
|
466 ####################################################################### |
|
467 |
|
468 def __writeJsonGraphicsFile(self, filename): |
|
469 """ |
|
470 Private method to write an eric graphics file using the JSON based |
|
471 file format. |
|
472 |
|
473 @param filename name of the file to write to |
|
474 @type str |
|
475 @return flag indicating a successful write |
|
476 @rtype bool |
|
477 """ |
|
478 data = { |
|
479 "version": "1.0", |
|
480 "type": self.__diagramType.value, |
|
481 "title": self.__getDiagramTitel(self.__diagramType), |
|
482 "width": self.scene.width(), |
|
483 "height": self.scene.height(), |
|
484 "builder": self.builder.toDict(), |
|
485 "view": self.umlView.toDict(), |
|
486 } |
|
487 |
|
488 try: |
|
489 jsonString = json.dumps(data, indent=2) |
|
490 with open(filename, "w") as f: |
|
491 f.write(jsonString) |
|
492 return True |
|
493 except (TypeError, OSError) as err: |
|
494 E5MessageBox.critical( |
|
495 self, |
|
496 self.tr("Save Diagram"), |
|
497 self.tr( |
|
498 """<p>The file <b>{0}</b> could not be saved.</p>""" |
|
499 """<p>Reason: {1}</p>""").format(filename, str(err)) |
|
500 ) |
|
501 return False |
|
502 |
|
503 def __readJsonGraphicsFile(self, filename): |
|
504 """ |
|
505 Private method to read an eric graphics file using the JSON based |
|
506 file format. |
|
507 |
|
508 @param filename name of the file to be read |
|
509 @type str |
|
510 @return flag indicating a successful read |
|
511 @rtype bool |
|
512 """ |
|
513 try: |
|
514 with open(filename, "r") as f: |
|
515 jsonString = f.read() |
|
516 data = json.loads(jsonString) |
|
517 except (OSError, json.JSONDecodeError) as err: |
|
518 E5MessageBox.critical( |
|
519 None, |
|
520 self.tr("Load Diagram"), |
|
521 self.tr( |
|
522 """<p>The file <b>{0}</b> could not be read.</p>""" |
|
523 """<p>Reason: {1}</p>""").format(filename, str(err)) |
|
524 ) |
|
525 return False |
|
526 |
|
527 try: |
|
528 # step 1: check version |
|
529 if data["version"] in UMLDialog.JsonFileVersions: |
|
530 version = data["version"] |
|
531 else: |
|
532 self.__showInvalidDataMessage(filename) |
|
533 return False |
|
534 |
|
535 # step 2: set diagram type |
|
536 try: |
|
537 self.__diagramType = UMLDialogType(data["type"]) |
|
538 except ValueError: |
|
539 self.__showInvalidDataMessage(filename) |
|
540 return False |
|
541 self.scene.clear() |
|
542 self.builder = self.__diagramBuilder(self.__diagramType, "") |
|
543 |
|
544 # step 3: set scene size |
|
545 self.umlView.setSceneSize(data["width"], data["height"]) |
|
546 |
|
547 # step 4: extract builder data if available |
|
548 ok, msg = self.builder.fromDict(version, data["builder"]) |
|
549 if not ok: |
|
550 if msg: |
|
551 res = E5MessageBox.warning( |
|
552 self, |
|
553 self.tr("Load Diagram"), |
|
554 msg, |
|
555 E5MessageBox.StandardButtons( |
|
556 E5MessageBox.Abort | |
|
557 E5MessageBox.Ignore), |
|
558 E5MessageBox.Abort) |
|
559 if res == E5MessageBox.Abort: |
|
560 return False |
|
561 else: |
|
562 self.umlView.setLayoutActionsEnabled(False) |
|
563 else: |
|
564 self.__showInvalidDataMessage(filename) |
|
565 return False |
|
566 |
|
567 # step 5: extract the graphics items |
|
568 ok = self.umlView.fromDict(version, data["view"]) |
|
569 if not ok: |
|
570 self.__showInvalidDataMessage(filename) |
|
571 return False |
|
572 except KeyError: |
|
573 self.__showInvalidDataMessage(filename) |
|
574 return False |
|
575 |
|
576 # everything worked fine, so remember the file name and set the |
|
577 # window title |
|
578 self.setWindowTitle(self.__getDiagramTitel(self.__diagramType)) |
|
579 self.__fileName = filename |
|
580 |
|
581 return True |