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