|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2007 - 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a canvas view class. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 |
|
12 import sys |
|
13 |
|
14 from PyQt5.QtCore import pyqtSignal, QRectF, QSize, QSizeF, Qt |
|
15 from PyQt5.QtGui import QBrush, QPainter, QPixmap, QFont, QColor |
|
16 from PyQt5.QtWidgets import QGraphicsView |
|
17 |
|
18 import Preferences |
|
19 |
|
20 |
|
21 class E5GraphicsView(QGraphicsView): |
|
22 """ |
|
23 Class implementing a graphics view. |
|
24 |
|
25 @signal zoomValueChanged(int) emitted to signal a change of the zoom value |
|
26 """ |
|
27 zoomValueChanged = pyqtSignal(int) |
|
28 |
|
29 ZoomLevels = [ |
|
30 1, 3, 5, 7, 9, |
|
31 10, 20, 30, 50, 67, 80, 90, |
|
32 100, |
|
33 110, 120, 133, 150, 170, 200, 240, 300, 400, |
|
34 500, 600, 700, 800, 900, 1000, |
|
35 ] |
|
36 ZoomLevelDefault = 100 |
|
37 |
|
38 def __init__(self, scene, parent=None): |
|
39 """ |
|
40 Constructor |
|
41 |
|
42 @param scene reference to the scene object (QGraphicsScene) |
|
43 @param parent parent widget (QWidget) |
|
44 """ |
|
45 super(E5GraphicsView, self).__init__(scene, parent) |
|
46 self.setObjectName("E5GraphicsView") |
|
47 |
|
48 self.__initialSceneSize = self.scene().sceneRect().size() |
|
49 self.setBackgroundBrush(QBrush(Qt.white)) |
|
50 self.setRenderHint(QPainter.Antialiasing, True) |
|
51 self.setDragMode(QGraphicsView.RubberBandDrag) |
|
52 self.setAlignment(Qt.Alignment(Qt.AlignLeft | Qt.AlignTop)) |
|
53 self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn) |
|
54 self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) |
|
55 self.setViewportUpdateMode(QGraphicsView.SmartViewportUpdate) |
|
56 |
|
57 self.setWhatsThis(self.tr( |
|
58 "<b>Graphics View</b>\n" |
|
59 "<p>This graphics view is used to show a diagram. \n" |
|
60 "There are various actions available to manipulate the \n" |
|
61 "shown items.</p>\n" |
|
62 "<ul>\n" |
|
63 "<li>Clicking on an item selects it.</li>\n" |
|
64 "<li>Ctrl-clicking adds an item to the selection.</li>\n" |
|
65 "<li>Ctrl-clicking a selected item deselects it.</li>\n" |
|
66 "<li>Clicking on an empty spot of the canvas resets the selection." |
|
67 "</li>\n" |
|
68 "<li>Dragging the mouse over the canvas spans a rubberband to \n" |
|
69 "select multiple items.</li>\n" |
|
70 "<li>Dragging the mouse over a selected item moves the \n" |
|
71 "whole selection.</li>\n" |
|
72 "</ul>\n" |
|
73 )) |
|
74 |
|
75 def __levelForZoom(self, zoom): |
|
76 """ |
|
77 Private method determining the zoom level index given a zoom factor. |
|
78 |
|
79 @param zoom zoom factor (integer) |
|
80 @return index of zoom factor (integer) |
|
81 """ |
|
82 try: |
|
83 index = E5GraphicsView.ZoomLevels.index(zoom) |
|
84 except ValueError: |
|
85 for index in range(len(E5GraphicsView.ZoomLevels)): |
|
86 if zoom <= E5GraphicsView.ZoomLevels[index]: |
|
87 break |
|
88 return index |
|
89 |
|
90 def zoomIn(self): |
|
91 """ |
|
92 Public method to zoom in. |
|
93 """ |
|
94 index = self.__levelForZoom(self.zoom()) |
|
95 if index < len(E5GraphicsView.ZoomLevels) - 1: |
|
96 self.setZoom(E5GraphicsView.ZoomLevels[index + 1]) |
|
97 |
|
98 def zoomOut(self): |
|
99 """ |
|
100 Public method to zoom out. |
|
101 """ |
|
102 index = self.__levelForZoom(self.zoom()) |
|
103 if index > 0: |
|
104 self.setZoom(E5GraphicsView.ZoomLevels[index - 1]) |
|
105 |
|
106 def zoomReset(self): |
|
107 """ |
|
108 Public method to handle the reset the zoom value. |
|
109 """ |
|
110 self.setZoom( |
|
111 E5GraphicsView.ZoomLevels[E5GraphicsView.ZoomLevelDefault]) |
|
112 |
|
113 def setZoom(self, value): |
|
114 """ |
|
115 Public method to set the zoom value in percent. |
|
116 |
|
117 @param value zoom value in percent (integer) |
|
118 """ |
|
119 if value != self.zoom(): |
|
120 self.resetTransform() |
|
121 factor = value / 100.0 |
|
122 self.scale(factor, factor) |
|
123 self.zoomValueChanged.emit(value) |
|
124 |
|
125 def zoom(self): |
|
126 """ |
|
127 Public method to get the current zoom factor in percent. |
|
128 |
|
129 @return current zoom factor in percent (integer) |
|
130 """ |
|
131 return int(self.transform().m11() * 100.0) |
|
132 |
|
133 def resizeScene(self, amount, isWidth=True): |
|
134 """ |
|
135 Public method to resize the scene. |
|
136 |
|
137 @param amount size increment (integer) |
|
138 @param isWidth flag indicating width is to be resized (boolean) |
|
139 """ |
|
140 sceneRect = self.scene().sceneRect() |
|
141 width = sceneRect.width() |
|
142 height = sceneRect.height() |
|
143 if isWidth: |
|
144 width += amount |
|
145 else: |
|
146 height += amount |
|
147 rect = self._getDiagramRect(10) |
|
148 if width < rect.width(): |
|
149 width = rect.width() |
|
150 if height < rect.height(): |
|
151 height = rect.height() |
|
152 |
|
153 self.setSceneSize(width, height) |
|
154 |
|
155 def setSceneSize(self, width, height): |
|
156 """ |
|
157 Public method to set the scene size. |
|
158 |
|
159 @param width width for the scene (real) |
|
160 @param height height for the scene (real) |
|
161 """ |
|
162 rect = self.scene().sceneRect() |
|
163 rect.setHeight(height) |
|
164 rect.setWidth(width) |
|
165 self.scene().setSceneRect(rect) |
|
166 |
|
167 def autoAdjustSceneSize(self, limit=False): |
|
168 """ |
|
169 Public method to adjust the scene size to the diagram size. |
|
170 |
|
171 @param limit flag indicating to limit the scene to the |
|
172 initial size (boolean) |
|
173 """ |
|
174 size = self._getDiagramSize(10) |
|
175 if limit: |
|
176 newWidth = max(size.width(), self.__initialSceneSize.width()) |
|
177 newHeight = max(size.height(), self.__initialSceneSize.height()) |
|
178 else: |
|
179 newWidth = size.width() |
|
180 newHeight = size.height() |
|
181 self.setSceneSize(newWidth, newHeight) |
|
182 |
|
183 def _getDiagramRect(self, border=0): |
|
184 """ |
|
185 Protected method to calculate the minimum rectangle fitting the |
|
186 diagram. |
|
187 |
|
188 @param border border width to include in the calculation (integer) |
|
189 @return the minimum rectangle (QRectF) |
|
190 """ |
|
191 startx = sys.maxsize |
|
192 starty = sys.maxsize |
|
193 endx = 0 |
|
194 endy = 0 |
|
195 items = self.filteredItems(list(self.scene().items())) |
|
196 for itm in items: |
|
197 rect = itm.sceneBoundingRect() |
|
198 itmEndX = rect.x() + rect.width() |
|
199 itmEndY = rect.y() + rect.height() |
|
200 itmStartX = rect.x() |
|
201 itmStartY = rect.y() |
|
202 if startx >= itmStartX: |
|
203 startx = itmStartX |
|
204 if starty >= itmStartY: |
|
205 starty = itmStartY |
|
206 if endx <= itmEndX: |
|
207 endx = itmEndX |
|
208 if endy <= itmEndY: |
|
209 endy = itmEndY |
|
210 if border: |
|
211 startx -= border |
|
212 starty -= border |
|
213 endx += border |
|
214 endy += border |
|
215 |
|
216 return QRectF(startx, starty, endx - startx + 1, endy - starty + 1) |
|
217 |
|
218 def _getDiagramSize(self, border=0): |
|
219 """ |
|
220 Protected method to calculate the minimum size fitting the diagram. |
|
221 |
|
222 @param border border width to include in the calculation (integer) |
|
223 @return the minimum size (QSizeF) |
|
224 """ |
|
225 endx = 0 |
|
226 endy = 0 |
|
227 items = self.filteredItems(list(self.scene().items())) |
|
228 for itm in items: |
|
229 rect = itm.sceneBoundingRect() |
|
230 itmEndX = rect.x() + rect.width() |
|
231 itmEndY = rect.y() + rect.height() |
|
232 if endx <= itmEndX: |
|
233 endx = itmEndX |
|
234 if endy <= itmEndY: |
|
235 endy = itmEndY |
|
236 if border: |
|
237 endx += border |
|
238 endy += border |
|
239 |
|
240 return QSizeF(endx + 1, endy + 1) |
|
241 |
|
242 def __getDiagram(self, rect, imageFormat="PNG", filename=None): |
|
243 """ |
|
244 Private method to retrieve the diagram from the scene fitting it |
|
245 in the minimum rectangle. |
|
246 |
|
247 @param rect minimum rectangle fitting the diagram (QRectF) |
|
248 @param imageFormat format for the image file (string) |
|
249 @param filename name of the file for non pixmaps (string) |
|
250 @return diagram pixmap to receive the diagram (QPixmap) |
|
251 """ |
|
252 selectedItems = self.scene().selectedItems() |
|
253 |
|
254 # step 1: deselect all widgets |
|
255 if selectedItems: |
|
256 for item in selectedItems: |
|
257 item.setSelected(False) |
|
258 |
|
259 # step 2: grab the diagram |
|
260 if imageFormat == "PNG": |
|
261 paintDevice = QPixmap(int(rect.width()), int(rect.height())) |
|
262 paintDevice.fill(self.backgroundBrush().color()) |
|
263 else: |
|
264 from PyQt5.QtSvg import QSvgGenerator |
|
265 paintDevice = QSvgGenerator() |
|
266 paintDevice.setResolution(100) # 100 dpi |
|
267 paintDevice.setSize(QSize(int(rect.width()), int(rect.height()))) |
|
268 paintDevice.setViewBox(rect) |
|
269 paintDevice.setFileName(filename) |
|
270 painter = QPainter(paintDevice) |
|
271 painter.setRenderHint(QPainter.Antialiasing, True) |
|
272 self.scene().render(painter, QRectF(), rect) |
|
273 |
|
274 # step 3: reselect the widgets |
|
275 if selectedItems: |
|
276 for item in selectedItems: |
|
277 item.setSelected(True) |
|
278 |
|
279 return paintDevice |
|
280 |
|
281 def saveImage(self, filename, imageFormat="PNG"): |
|
282 """ |
|
283 Public method to save the scene to a file. |
|
284 |
|
285 @param filename name of the file to write the image to (string) |
|
286 @param imageFormat format for the image file (string) |
|
287 @return flag indicating success (boolean) |
|
288 """ |
|
289 rect = self._getDiagramRect(self.border) |
|
290 if imageFormat == "SVG": |
|
291 self.__getDiagram(rect, imageFormat=imageFormat, filename=filename) |
|
292 return True |
|
293 else: |
|
294 pixmap = self.__getDiagram(rect) |
|
295 return pixmap.save(filename, imageFormat) |
|
296 |
|
297 def printDiagram(self, printer, diagramName=""): |
|
298 """ |
|
299 Public method to print the diagram. |
|
300 |
|
301 @param printer reference to a ready configured printer object |
|
302 (QPrinter) |
|
303 @param diagramName name of the diagram (string) |
|
304 """ |
|
305 painter = QPainter() |
|
306 painter.begin(printer) |
|
307 offsetX = 0 |
|
308 offsetY = 0 |
|
309 widthX = 0 |
|
310 heightY = 0 |
|
311 font = QFont("times", 10) |
|
312 painter.setFont(font) |
|
313 fm = painter.fontMetrics() |
|
314 fontHeight = fm.lineSpacing() |
|
315 marginX = printer.pageRect().x() - printer.paperRect().x() |
|
316 marginX = \ |
|
317 Preferences.getPrinter("LeftMargin") * int( |
|
318 printer.resolution() / 2.54) - marginX |
|
319 marginY = printer.pageRect().y() - printer.paperRect().y() |
|
320 marginY = \ |
|
321 Preferences.getPrinter("TopMargin") * int( |
|
322 printer.resolution() / 2.54) - marginY |
|
323 |
|
324 width = printer.width() - marginX \ |
|
325 - Preferences.getPrinter("RightMargin") * int( |
|
326 printer.resolution() / 2.54) |
|
327 height = printer.height() - fontHeight - 4 - marginY \ |
|
328 - Preferences.getPrinter("BottomMargin") * int( |
|
329 printer.resolution() / 2.54) |
|
330 |
|
331 border = self.border == 0 and 5 or self.border |
|
332 rect = self._getDiagramRect(border) |
|
333 diagram = self.__getDiagram(rect) |
|
334 |
|
335 finishX = False |
|
336 finishY = False |
|
337 page = 0 |
|
338 pageX = 0 |
|
339 pageY = 1 |
|
340 while not finishX or not finishY: |
|
341 if not finishX: |
|
342 offsetX = pageX * width |
|
343 pageX += 1 |
|
344 elif not finishY: |
|
345 offsetY = pageY * height |
|
346 offsetX = 0 |
|
347 pageY += 1 |
|
348 finishX = False |
|
349 pageX = 1 |
|
350 if (width + offsetX) > diagram.width(): |
|
351 finishX = True |
|
352 widthX = diagram.width() - offsetX |
|
353 else: |
|
354 widthX = width |
|
355 if diagram.width() < width: |
|
356 widthX = diagram.width() |
|
357 finishX = True |
|
358 offsetX = 0 |
|
359 if (height + offsetY) > diagram.height(): |
|
360 finishY = True |
|
361 heightY = diagram.height() - offsetY |
|
362 else: |
|
363 heightY = height |
|
364 if diagram.height() < height: |
|
365 finishY = True |
|
366 heightY = diagram.height() |
|
367 offsetY = 0 |
|
368 |
|
369 painter.drawPixmap(marginX, marginY, diagram, |
|
370 offsetX, offsetY, widthX, heightY) |
|
371 # write a foot note |
|
372 s = self.tr("{0}, Page {1}").format(diagramName, page + 1) |
|
373 tc = QColor(50, 50, 50) |
|
374 painter.setPen(tc) |
|
375 painter.drawRect(marginX, marginY, width, height) |
|
376 painter.drawLine(marginX, marginY + height + 2, |
|
377 marginX + width, marginY + height + 2) |
|
378 painter.setFont(font) |
|
379 painter.drawText(marginX, marginY + height + 4, width, |
|
380 fontHeight, Qt.AlignRight, s) |
|
381 if not finishX or not finishY: |
|
382 printer.newPage() |
|
383 page += 1 |
|
384 |
|
385 painter.end() |
|
386 |
|
387 ########################################################################### |
|
388 ## The methods below should be overridden by subclasses to get special |
|
389 ## behavior. |
|
390 ########################################################################### |
|
391 |
|
392 def filteredItems(self, items): |
|
393 """ |
|
394 Public method to filter a list of items. |
|
395 |
|
396 @param items list of items as returned by the scene object |
|
397 (QGraphicsItem) |
|
398 @return list of interesting collision items (QGraphicsItem) |
|
399 """ |
|
400 # just return the list unchanged |
|
401 return items |