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