|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2012 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a grabber widget for a freehand snapshot region. |
|
8 """ |
|
9 |
|
10 from PyQt6.QtCore import pyqtSignal, Qt, QRect, QPoint, QTimer, QLocale |
|
11 from PyQt6.QtGui import ( |
|
12 QPixmap, QColor, QRegion, QPainter, QPalette, QPolygon, QPen, QBrush, |
|
13 QPaintEngine, QGuiApplication, QCursor |
|
14 ) |
|
15 from PyQt6.QtWidgets import QWidget, QToolTip |
|
16 |
|
17 import Globals |
|
18 |
|
19 |
|
20 def drawPolygon(painter, polygon, outline, fill=None): |
|
21 """ |
|
22 Module function to draw a polygon with the given parameters. |
|
23 |
|
24 @param painter reference to the painter to be used (QPainter) |
|
25 @param polygon polygon to be drawn (QPolygon) |
|
26 @param outline color of the outline (QColor) |
|
27 @param fill fill color (QColor) |
|
28 """ |
|
29 clip = QRegion(polygon) |
|
30 clip -= QRegion(polygon) |
|
31 pen = QPen(outline, 1, Qt.PenStyle.SolidLine, Qt.PenCapStyle.SquareCap, |
|
32 Qt.PenJoinStyle.BevelJoin) |
|
33 |
|
34 painter.save() |
|
35 painter.setClipRegion(clip) |
|
36 painter.setPen(pen) |
|
37 painter.drawPolygon(QPolygon(polygon)) |
|
38 if fill and fill.isValid(): |
|
39 painter.setClipping(False) |
|
40 painter.setBrush(fill or QColor()) |
|
41 painter.drawPolygon(QPolygon(polygon)) |
|
42 painter.restore() |
|
43 |
|
44 |
|
45 class SnapshotFreehandGrabber(QWidget): |
|
46 """ |
|
47 Class implementing a grabber widget for a freehand snapshot region. |
|
48 |
|
49 @signal grabbed(QPixmap) emitted after the region was grabbed |
|
50 """ |
|
51 grabbed = pyqtSignal(QPixmap) |
|
52 |
|
53 def __init__(self): |
|
54 """ |
|
55 Constructor |
|
56 """ |
|
57 super().__init__( |
|
58 None, |
|
59 Qt.WindowType.X11BypassWindowManagerHint | |
|
60 Qt.WindowType.WindowStaysOnTopHint | |
|
61 Qt.WindowType.FramelessWindowHint | |
|
62 Qt.WindowType.Tool |
|
63 ) |
|
64 |
|
65 self.__selection = QPolygon() |
|
66 self.__mouseDown = False |
|
67 self.__newSelection = False |
|
68 self.__handleSize = 10 |
|
69 self.__showHelp = True |
|
70 self.__grabbing = False |
|
71 self.__dragStartPoint = QPoint() |
|
72 self.__selectionBeforeDrag = QPolygon() |
|
73 self.__locale = QLocale() |
|
74 |
|
75 self.__helpTextRect = QRect() |
|
76 self.__helpText = self.tr( |
|
77 "Select a region using the mouse. To take the snapshot," |
|
78 " press the Enter key or double click. Press Esc to quit.") |
|
79 |
|
80 self.__pixmap = QPixmap() |
|
81 self.__pBefore = QPoint() |
|
82 |
|
83 self.setMouseTracking(True) |
|
84 |
|
85 QTimer.singleShot(200, self.__initialize) |
|
86 |
|
87 def __initialize(self): |
|
88 """ |
|
89 Private slot to initialize the rest of the widget. |
|
90 """ |
|
91 if Globals.isMacPlatform(): |
|
92 # macOS variant |
|
93 screen = QGuiApplication.screenAt(QCursor.pos()) |
|
94 geom = screen.geometry() |
|
95 self.__pixmap = screen.grabWindow( |
|
96 0, geom.x(), geom.y(), geom.width(), geom.height()) |
|
97 else: |
|
98 # Linux variant |
|
99 # Windows variant |
|
100 screen = QGuiApplication.screens()[0] |
|
101 geom = screen.availableVirtualGeometry() |
|
102 self.__pixmap = screen.grabWindow( |
|
103 0, geom.x(), geom.y(), geom.width(), geom.height()) |
|
104 self.resize(self.__pixmap.size()) |
|
105 self.move(geom.x(), geom.y()) |
|
106 self.setCursor(Qt.CursorShape.CrossCursor) |
|
107 self.show() |
|
108 |
|
109 self.grabMouse() |
|
110 self.grabKeyboard() |
|
111 self.activateWindow() |
|
112 |
|
113 def paintEvent(self, evt): |
|
114 """ |
|
115 Protected method handling paint events. |
|
116 |
|
117 @param evt paint event (QPaintEvent) |
|
118 """ |
|
119 if self.__grabbing: # grabWindow() should just get the background |
|
120 return |
|
121 |
|
122 painter = QPainter(self) |
|
123 pal = QPalette(QToolTip.palette()) |
|
124 font = QToolTip.font() |
|
125 |
|
126 handleColor = pal.color(QPalette.ColorGroup.Active, |
|
127 QPalette.ColorRole.Highlight) |
|
128 handleColor.setAlpha(160) |
|
129 overlayColor = QColor(0, 0, 0, 160) |
|
130 textColor = pal.color(QPalette.ColorGroup.Active, |
|
131 QPalette.ColorRole.Text) |
|
132 textBackgroundColor = pal.color(QPalette.ColorGroup.Active, |
|
133 QPalette.ColorRole.Base) |
|
134 painter.drawPixmap(0, 0, self.__pixmap) |
|
135 painter.setFont(font) |
|
136 |
|
137 pol = QPolygon(self.__selection) |
|
138 if not self.__selection.boundingRect().isNull(): |
|
139 # Draw outline around selection. |
|
140 # Important: the 1px-wide outline is *also* part of the |
|
141 # captured free-region |
|
142 pen = QPen(handleColor, 1, Qt.PenStyle.SolidLine, |
|
143 Qt.PenCapStyle.SquareCap, Qt.PenJoinStyle.BevelJoin) |
|
144 painter.setPen(pen) |
|
145 painter.drawPolygon(pol) |
|
146 |
|
147 # Draw the grey area around the selection. |
|
148 grey = QRegion(self.rect()) |
|
149 grey -= QRegion(pol) |
|
150 painter.setClipRegion(grey) |
|
151 painter.setPen(Qt.PenStyle.NoPen) |
|
152 painter.setBrush(overlayColor) |
|
153 painter.drawRect(self.rect()) |
|
154 painter.setClipRect(self.rect()) |
|
155 drawPolygon(painter, pol, handleColor) |
|
156 |
|
157 if self.__showHelp: |
|
158 painter.setPen(textColor) |
|
159 painter.setBrush(textBackgroundColor) |
|
160 self.__helpTextRect = painter.boundingRect( |
|
161 self.rect().adjusted(2, 2, -2, -2), |
|
162 Qt.TextFlag.TextWordWrap, self.__helpText).translated(0, 0) |
|
163 self.__helpTextRect.adjust(-2, -2, 4, 2) |
|
164 drawPolygon(painter, self.__helpTextRect, textColor, |
|
165 textBackgroundColor) |
|
166 painter.drawText( |
|
167 self.__helpTextRect.adjusted(3, 3, -3, -3), |
|
168 Qt.TextFlag.TextWordWrap, self.__helpText) |
|
169 |
|
170 if self.__selection.isEmpty(): |
|
171 return |
|
172 |
|
173 # The grabbed region is everything which is covered by the drawn |
|
174 # rectangles (border included). This means that there is no 0px |
|
175 # selection, since a 0px wide rectangle will always be drawn as a line. |
|
176 boundingRect = self.__selection.boundingRect() |
|
177 txt = "{0}, {1} ({2} x {3})".format( |
|
178 self.__locale.toString(boundingRect.x()), |
|
179 self.__locale.toString(boundingRect.y()), |
|
180 self.__locale.toString(boundingRect.width()), |
|
181 self.__locale.toString(boundingRect.height()) |
|
182 ) |
|
183 textRect = painter.boundingRect(self.rect(), |
|
184 Qt.AlignmentFlag.AlignLeft, txt) |
|
185 boundingRect = textRect.adjusted(-4, 0, 0, 0) |
|
186 |
|
187 polBoundingRect = pol.boundingRect() |
|
188 if ( |
|
189 (textRect.width() < |
|
190 polBoundingRect.width() - 2 * self.__handleSize) and |
|
191 (textRect.height() < |
|
192 polBoundingRect.height() - 2 * self.__handleSize) and |
|
193 polBoundingRect.width() > 100 and |
|
194 polBoundingRect.height() > 100 |
|
195 ): |
|
196 # center, unsuitable for small selections |
|
197 boundingRect.moveCenter(polBoundingRect.center()) |
|
198 textRect.moveCenter(polBoundingRect.center()) |
|
199 elif ( |
|
200 polBoundingRect.y() - 3 > textRect.height() and |
|
201 polBoundingRect.x() + textRect.width() < self.rect().width() |
|
202 ): |
|
203 # on top, left aligned |
|
204 boundingRect.moveBottomLeft( |
|
205 QPoint(polBoundingRect.x(), polBoundingRect.y() - 3)) |
|
206 textRect.moveBottomLeft( |
|
207 QPoint(polBoundingRect.x() + 2, polBoundingRect.y() - 3)) |
|
208 elif polBoundingRect.x() - 3 > textRect.width(): |
|
209 # left, top aligned |
|
210 boundingRect.moveTopRight( |
|
211 QPoint(polBoundingRect.x() - 3, polBoundingRect.y())) |
|
212 textRect.moveTopRight( |
|
213 QPoint(polBoundingRect.x() - 5, polBoundingRect.y())) |
|
214 elif ( |
|
215 (polBoundingRect.bottom() + 3 + textRect.height() < |
|
216 self.rect().bottom()) and |
|
217 polBoundingRect.right() > textRect.width() |
|
218 ): |
|
219 # at bottom, right aligned |
|
220 boundingRect.moveTopRight( |
|
221 QPoint(polBoundingRect.right(), polBoundingRect.bottom() + 3)) |
|
222 textRect.moveTopRight( |
|
223 QPoint(polBoundingRect.right() - 2, |
|
224 polBoundingRect.bottom() + 3)) |
|
225 elif ( |
|
226 polBoundingRect.right() + textRect.width() + 3 < |
|
227 self.rect().width() |
|
228 ): |
|
229 # right, bottom aligned |
|
230 boundingRect.moveBottomLeft( |
|
231 QPoint(polBoundingRect.right() + 3, polBoundingRect.bottom())) |
|
232 textRect.moveBottomLeft( |
|
233 QPoint(polBoundingRect.right() + 5, polBoundingRect.bottom())) |
|
234 |
|
235 # If the above didn't catch it, you are running on a very |
|
236 # tiny screen... |
|
237 drawPolygon(painter, boundingRect, textColor, textBackgroundColor) |
|
238 painter.drawText(textRect, Qt.AlignmentFlag.AlignHCenter, txt) |
|
239 |
|
240 if ( |
|
241 (polBoundingRect.height() > self.__handleSize * 2 and |
|
242 polBoundingRect.width() > self.__handleSize * 2) or |
|
243 not self.__mouseDown |
|
244 ): |
|
245 painter.setBrush(Qt.GlobalColor.transparent) |
|
246 painter.setClipRegion(QRegion(pol)) |
|
247 painter.drawPolygon(QPolygon(self.rect())) |
|
248 |
|
249 def mousePressEvent(self, evt): |
|
250 """ |
|
251 Protected method to handle mouse button presses. |
|
252 |
|
253 @param evt mouse press event (QMouseEvent) |
|
254 """ |
|
255 self.__pBefore = evt.position().toPoint() |
|
256 |
|
257 self.__showHelp = not self.__helpTextRect.contains( |
|
258 evt.position().toPoint()) |
|
259 if evt.button() == Qt.MouseButton.LeftButton: |
|
260 self.__mouseDown = True |
|
261 self.__dragStartPoint = evt.position().toPoint() |
|
262 self.__selectionBeforeDrag = QPolygon(self.__selection) |
|
263 if not self.__selection.containsPoint(evt.position().toPoint(), |
|
264 Qt.FillRule.WindingFill): |
|
265 self.__newSelection = True |
|
266 self.__selection = QPolygon() |
|
267 else: |
|
268 self.setCursor(Qt.CursorShape.ClosedHandCursor) |
|
269 elif evt.button() == Qt.MouseButton.RightButton: |
|
270 self.__newSelection = False |
|
271 self.__selection = QPolygon() |
|
272 self.setCursor(Qt.CursorShape.CrossCursor) |
|
273 self.update() |
|
274 |
|
275 def mouseMoveEvent(self, evt): |
|
276 """ |
|
277 Protected method to handle mouse movements. |
|
278 |
|
279 @param evt mouse move event (QMouseEvent) |
|
280 """ |
|
281 shouldShowHelp = not self.__helpTextRect.contains( |
|
282 evt.position().toPoint()) |
|
283 if shouldShowHelp != self.__showHelp: |
|
284 self.__showHelp = shouldShowHelp |
|
285 self.update() |
|
286 |
|
287 if self.__mouseDown: |
|
288 if self.__newSelection: |
|
289 p = evt.position().toPoint() |
|
290 self.__selection.append(p) |
|
291 else: |
|
292 # moving the whole selection |
|
293 p = evt.position().toPoint() - self.__pBefore # Offset |
|
294 self.__pBefore = evt.position().toPoint() |
|
295 # save position for next iteration |
|
296 self.__selection.translate(p) |
|
297 |
|
298 self.update() |
|
299 else: |
|
300 if self.__selection.boundingRect().isEmpty(): |
|
301 return |
|
302 |
|
303 if self.__selection.containsPoint(evt.position().toPoint(), |
|
304 Qt.FillRule.WindingFill): |
|
305 self.setCursor(Qt.CursorShape.OpenHandCursor) |
|
306 else: |
|
307 self.setCursor(Qt.CursorShape.CrossCursor) |
|
308 |
|
309 def mouseReleaseEvent(self, evt): |
|
310 """ |
|
311 Protected method to handle mouse button releases. |
|
312 |
|
313 @param evt mouse release event (QMouseEvent) |
|
314 """ |
|
315 self.__mouseDown = False |
|
316 self.__newSelection = False |
|
317 if self.__selection.containsPoint(evt.position().toPoint(), |
|
318 Qt.FillRule.WindingFill): |
|
319 self.setCursor(Qt.CursorShape.OpenHandCursor) |
|
320 self.update() |
|
321 |
|
322 def mouseDoubleClickEvent(self, evt): |
|
323 """ |
|
324 Protected method to handle mouse double clicks. |
|
325 |
|
326 @param evt mouse double click event (QMouseEvent) |
|
327 """ |
|
328 self.__grabRegion() |
|
329 |
|
330 def keyPressEvent(self, evt): |
|
331 """ |
|
332 Protected method to handle key presses. |
|
333 |
|
334 @param evt key press event (QKeyEvent) |
|
335 """ |
|
336 if evt.key() == Qt.Key.Key_Escape: |
|
337 self.grabbed.emit(QPixmap()) |
|
338 elif evt.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return]: |
|
339 self.__grabRegion() |
|
340 else: |
|
341 evt.ignore() |
|
342 |
|
343 def __grabRegion(self): |
|
344 """ |
|
345 Private method to grab the selected region (i.e. do the snapshot). |
|
346 """ |
|
347 pol = QPolygon(self.__selection) |
|
348 if not pol.isEmpty(): |
|
349 self.__grabbing = True |
|
350 |
|
351 xOffset = self.__pixmap.rect().x() - pol.boundingRect().x() |
|
352 yOffset = self.__pixmap.rect().y() - pol.boundingRect().y() |
|
353 translatedPol = pol.translated(xOffset, yOffset) |
|
354 |
|
355 pixmap2 = QPixmap(pol.boundingRect().size()) |
|
356 pixmap2.fill(Qt.GlobalColor.transparent) |
|
357 |
|
358 pt = QPainter() |
|
359 pt.begin(pixmap2) |
|
360 if pt.paintEngine().hasFeature( |
|
361 QPaintEngine.PaintEngineFeature.PorterDuff |
|
362 ): |
|
363 pt.setRenderHints( |
|
364 QPainter.RenderHint.Antialiasing | |
|
365 QPainter.RenderHint.SmoothPixmapTransform, |
|
366 True) |
|
367 pt.setBrush(Qt.GlobalColor.black) |
|
368 pt.setPen(QPen(QBrush(Qt.GlobalColor.black), 0.5)) |
|
369 pt.drawPolygon(translatedPol) |
|
370 pt.setCompositionMode( |
|
371 QPainter.CompositionMode.CompositionMode_SourceIn) |
|
372 else: |
|
373 pt.setClipRegion(QRegion(translatedPol)) |
|
374 pt.setCompositionMode( |
|
375 QPainter.CompositionMode.CompositionMode_Source) |
|
376 |
|
377 pt.drawPixmap(pixmap2.rect(), self.__pixmap, pol.boundingRect()) |
|
378 pt.end() |
|
379 |
|
380 self.grabbed.emit(pixmap2) |