1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2012 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a grabber widget for a rectangular snapshot region.
8"""
9
10from PyQt5.QtCore import pyqtSignal, Qt, QRect, QPoint, QTimer, QLocale
11from PyQt5.QtGui import (
12    QPixmap, QColor, QRegion, QPainter, QPalette, QPaintEngine, QPen, QBrush,
13    QGuiApplication, QCursor
14)
15from PyQt5.QtWidgets import QWidget, QToolTip
16
17import Globals
18
19
20def drawRect(painter, rect, outline, fill=None):
21    """
22    Module function to draw a rectangle with the given parameters.
23
24    @param painter reference to the painter to be used (QPainter)
25    @param rect rectangle to be drawn (QRect)
26    @param outline color of the outline (QColor)
27    @param fill fill color (QColor)
28    """
29    clip = QRegion(rect)
30    clip = clip.subtracted(QRegion(rect.adjusted(1, 1, -1, -1)))
31
32    painter.save()
33    painter.setClipRegion(clip)
34    painter.setPen(Qt.PenStyle.NoPen)
35    painter.setBrush(outline)
36    painter.drawRect(rect)
37    if fill is not None and fill.isValid():
38        painter.setClipping(False)
39        painter.setBrush(fill)
40        painter.drawRect(rect.adjusted(1, 1, -1, -1))
41    painter.restore()
42
43
44class SnapshotRegionGrabber(QWidget):
45    """
46    Class implementing a grabber widget for a rectangular snapshot region.
47
48    @signal grabbed(QPixmap) emitted after the region was grabbed
49    """
50    grabbed = pyqtSignal(QPixmap)
51
52    StrokeMask = 0
53    FillMask = 1
54
55    Rectangle = 0
56    Ellipse = 1
57
58    def __init__(self, mode=Rectangle):
59        """
60        Constructor
61
62        @param mode region grabber mode (SnapshotRegionGrabber.Rectangle or
63            SnapshotRegionGrabber.Ellipse)
64        @exception ValueError raised to indicate a bad value for the 'mode'
65            parameter
66        """
67        super().__init__(
68            None,
69            Qt.WindowType.X11BypassWindowManagerHint |
70            Qt.WindowType.WindowStaysOnTopHint |
71            Qt.WindowType.FramelessWindowHint |
72            Qt.WindowType.Tool
73        )
74
75        if mode not in [SnapshotRegionGrabber.Rectangle,
76                        SnapshotRegionGrabber.Ellipse]:
77            raise ValueError("Bad value for 'mode' parameter.")
78        self.__mode = mode
79
80        self.__selection = QRect()
81        self.__mouseDown = False
82        self.__newSelection = False
83        self.__handleSize = 10
84        self.__mouseOverHandle = None
85        self.__showHelp = True
86        self.__grabbing = False
87        self.__dragStartPoint = QPoint()
88        self.__selectionBeforeDrag = QRect()
89        self.__locale = QLocale()
90
91        # naming conventions for handles
92        # T top, B bottom, R Right, L left
93        # 2 letters: a corner
94        # 1 letter: the handle on the middle of the corresponding side
95        self.__TLHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
96        self.__TRHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
97        self.__BLHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
98        self.__BRHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
99        self.__LHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
100        self.__THandle = QRect(0, 0, self.__handleSize, self.__handleSize)
101        self.__RHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
102        self.__BHandle = QRect(0, 0, self.__handleSize, self.__handleSize)
103        self.__handles = [self.__TLHandle, self.__TRHandle, self.__BLHandle,
104                          self.__BRHandle, self.__LHandle, self.__THandle,
105                          self.__RHandle, self.__BHandle]
106        self.__helpTextRect = QRect()
107        self.__helpText = self.tr(
108            "Select a region using the mouse. To take the snapshot, press"
109            " the Enter key or double click. Press Esc to quit.")
110
111        self.__pixmap = QPixmap()
112
113        self.setMouseTracking(True)
114
115        QTimer.singleShot(200, self.__initialize)
116
117    def __initialize(self):
118        """
119        Private slot to initialize the rest of the widget.
120        """
121        if Globals.isMacPlatform():
122            # macOS variant
123            screen = QGuiApplication.screenAt(QCursor.pos())
124            geom = screen.geometry()
125            self.__pixmap = screen.grabWindow(
126                0, geom.x(), geom.y(), geom.width(), geom.height())
127        else:
128            # Linux variant
129            # Windows variant
130            screen = QGuiApplication.screens()[0]
131            geom = screen.availableVirtualGeometry()
132            self.__pixmap = screen.grabWindow(
133                0, geom.x(), geom.y(), geom.width(), geom.height())
134        self.resize(self.__pixmap.size())
135        self.move(geom.x(), geom.y())
136        self.setCursor(Qt.CursorShape.CrossCursor)
137        self.show()
138
139        self.grabMouse()
140        self.grabKeyboard()
141        self.activateWindow()
142
143    def paintEvent(self, evt):
144        """
145        Protected method handling paint events.
146
147        @param evt paint event (QPaintEvent)
148        """
149        if self.__grabbing:     # grabWindow() should just get the background
150            return
151
152        painter = QPainter(self)
153        pal = QPalette(QToolTip.palette())
154        font = QToolTip.font()
155
156        handleColor = pal.color(QPalette.ColorGroup.Active,
157                                QPalette.ColorRole.Highlight)
158        handleColor.setAlpha(160)
159        overlayColor = QColor(0, 0, 0, 160)
160        textColor = pal.color(QPalette.ColorGroup.Active,
161                              QPalette.ColorRole.Text)
162        textBackgroundColor = pal.color(QPalette.ColorGroup.Active,
163                                        QPalette.ColorRole.Base)
164        painter.drawPixmap(0, 0, self.__pixmap)
165        painter.setFont(font)
166
167        r = QRect(self.__selection)
168        if not self.__selection.isNull():
169            grey = QRegion(self.rect())
170            if self.__mode == SnapshotRegionGrabber.Ellipse:
171                reg = QRegion(r, QRegion.RegionType.Ellipse)
172            else:
173                reg = QRegion(r)
174            grey = grey.subtracted(reg)
175            painter.setClipRegion(grey)
176            painter.setPen(Qt.PenStyle.NoPen)
177            painter.setBrush(overlayColor)
178            painter.drawRect(self.rect())
179            painter.setClipRect(self.rect())
180            drawRect(painter, r, handleColor)
181
182        if self.__showHelp:
183            painter.setPen(textColor)
184            painter.setBrush(textBackgroundColor)
185            self.__helpTextRect = painter.boundingRect(
186                self.rect().adjusted(2, 2, -2, -2),
187                Qt.TextFlag.TextWordWrap, self.__helpText).translated(0, 0)
188            self.__helpTextRect.adjust(-2, -2, 4, 2)
189            drawRect(painter, self.__helpTextRect, textColor,
190                     textBackgroundColor)
191            painter.drawText(
192                self.__helpTextRect.adjusted(3, 3, -3, -3),
193                Qt.TextFlag.TextWordWrap, self.__helpText)
194
195        if self.__selection.isNull():
196            return
197
198        # The grabbed region is everything which is covered by the drawn
199        # rectangles (border included). This means that there is no 0px
200        # selection, since a 0px wide rectangle will always be drawn as a line.
201        txt = "{0}, {1} ({2} x {3})".format(
202            self.__locale.toString(self.__selection.x()),
203            self.__locale.toString(self.__selection.y()),
204            self.__locale.toString(self.__selection.width()),
205            self.__locale.toString(self.__selection.height())
206        )
207        textRect = painter.boundingRect(self.rect(),
208                                        Qt.AlignmentFlag.AlignLeft, txt)
209        boundingRect = textRect.adjusted(-4, 0, 0, 0)
210
211        if (
212            textRect.width() < r.width() - 2 * self.__handleSize and
213            textRect.height() < r.height() - 2 * self.__handleSize and
214            r.width() > 100 and
215            r.height() > 100
216        ):
217            # center, unsuitable for small selections
218            boundingRect.moveCenter(r.center())
219            textRect.moveCenter(r.center())
220        elif (
221            r.y() - 3 > textRect.height() and
222            r.x() + textRect.width() < self.rect().width()
223        ):
224            # on top, left aligned
225            boundingRect.moveBottomLeft(QPoint(r.x(), r.y() - 3))
226            textRect.moveBottomLeft(QPoint(r.x() + 2, r.y() - 3))
227        elif r.x() - 3 > textRect.width():
228            # left, top aligned
229            boundingRect.moveTopRight(QPoint(r.x() - 3, r.y()))
230            textRect.moveTopRight(QPoint(r.x() - 5, r.y()))
231        elif (
232            r.bottom() + 3 + textRect.height() < self.rect().bottom() and
233            r.right() > textRect.width()
234        ):
235            # at bottom, right aligned
236            boundingRect.moveTopRight(QPoint(r.right(), r.bottom() + 3))
237            textRect.moveTopRight(QPoint(r.right() - 2, r.bottom() + 3))
238        elif r.right() + textRect.width() + 3 < self.rect().width():
239            # right, bottom aligned
240            boundingRect.moveBottomLeft(QPoint(r.right() + 3, r.bottom()))
241            textRect.moveBottomLeft(QPoint(r.right() + 5, r.bottom()))
242
243        # If the above didn't catch it, you are running on a very
244        # tiny screen...
245        drawRect(painter, boundingRect, textColor, textBackgroundColor)
246        painter.drawText(textRect, Qt.AlignmentFlag.AlignHCenter, txt)
247
248        if (
249            (r.height() > self.__handleSize * 2 and
250             r.width() > self.__handleSize * 2) or
251            not self.__mouseDown
252        ):
253            self.__updateHandles()
254            painter.setPen(Qt.PenStyle.NoPen)
255            painter.setBrush(handleColor)
256            painter.setClipRegion(
257                self.__handleMask(SnapshotRegionGrabber.StrokeMask))
258            painter.drawRect(self.rect())
259            handleColor.setAlpha(60)
260            painter.setBrush(handleColor)
261            painter.setClipRegion(
262                self.__handleMask(SnapshotRegionGrabber.FillMask))
263            painter.drawRect(self.rect())
264
265    def resizeEvent(self, evt):
266        """
267        Protected method to handle resize events.
268
269        @param evt resize event (QResizeEvent)
270        """
271        if self.__selection.isNull():
272            return
273
274        r = QRect(self.__selection)
275        r.setTopLeft(self.__limitPointToRect(r.topLeft(), self.rect()))
276        r.setBottomRight(self.__limitPointToRect(r.bottomRight(), self.rect()))
277        if r.width() <= 1 or r.height() <= 1:
278            # This just results in ugly drawing...
279            self.__selection = QRect()
280        else:
281            self.__selection = self.__normalizeSelection(r)
282
283    def mousePressEvent(self, evt):
284        """
285        Protected method to handle mouse button presses.
286
287        @param evt mouse press event (QMouseEvent)
288        """
289        self.__showHelp = not self.__helpTextRect.contains(evt.pos())
290        if evt.button() == Qt.MouseButton.LeftButton:
291            self.__mouseDown = True
292            self.__dragStartPoint = evt.pos()
293            self.__selectionBeforeDrag = QRect(self.__selection)
294            if not self.__selection.contains(evt.pos()):
295                self.__newSelection = True
296                self.__selection = QRect()
297            else:
298                self.setCursor(Qt.CursorShape.ClosedHandCursor)
299        elif evt.button() == Qt.MouseButton.RightButton:
300            self.__newSelection = False
301            self.__selection = QRect()
302            self.setCursor(Qt.CursorShape.CrossCursor)
303        self.update()
304
305    def mouseMoveEvent(self, evt):
306        """
307        Protected method to handle mouse movements.
308
309        @param evt mouse move event (QMouseEvent)
310        """
311        shouldShowHelp = not self.__helpTextRect.contains(evt.pos())
312        if shouldShowHelp != self.__showHelp:
313            self.__showHelp = shouldShowHelp
314            self.update()
315
316        if self.__mouseDown:
317            if self.__newSelection:
318                p = evt.pos()
319                r = self.rect()
320                self.__selection = self.__normalizeSelection(
321                    QRect(self.__dragStartPoint,
322                          self.__limitPointToRect(p, r)))
323            elif self.__mouseOverHandle is None:
324                # moving the whole selection
325                r = self.rect().normalized()
326                s = self.__selectionBeforeDrag.normalized()
327                p = s.topLeft() + evt.pos() - self.__dragStartPoint
328                r.setBottomRight(
329                    r.bottomRight() - QPoint(s.width(), s.height()) +
330                    QPoint(1, 1))
331                if not r.isNull() and r.isValid():
332                    self.__selection.moveTo(self.__limitPointToRect(p, r))
333            else:
334                # dragging a handle
335                r = QRect(self.__selectionBeforeDrag)
336                offset = evt.pos() - self.__dragStartPoint
337
338                if self.__mouseOverHandle in [
339                        self.__TLHandle, self.__THandle, self.__TRHandle]:
340                    r.setTop(r.top() + offset.y())
341
342                if self.__mouseOverHandle in [
343                        self.__TLHandle, self.__LHandle, self.__BLHandle]:
344                    r.setLeft(r.left() + offset.x())
345
346                if self.__mouseOverHandle in [
347                        self.__BLHandle, self.__BHandle, self.__BRHandle]:
348                    r.setBottom(r.bottom() + offset.y())
349
350                if self.__mouseOverHandle in [
351                        self.__TRHandle, self.__RHandle, self.__BRHandle]:
352                    r.setRight(r.right() + offset.x())
353
354                r.setTopLeft(self.__limitPointToRect(r.topLeft(), self.rect()))
355                r.setBottomRight(
356                    self.__limitPointToRect(r.bottomRight(), self.rect()))
357                self.__selection = self.__normalizeSelection(r)
358
359            self.update()
360        else:
361            if self.__selection.isNull():
362                return
363
364            found = False
365            for r in self.__handles:
366                if r.contains(evt.pos()):
367                    self.__mouseOverHandle = r
368                    found = True
369                    break
370
371            if not found:
372                self.__mouseOverHandle = None
373                if self.__selection.contains(evt.pos()):
374                    self.setCursor(Qt.CursorShape.OpenHandCursor)
375                else:
376                    self.setCursor(Qt.CursorShape.CrossCursor)
377            else:
378                if self.__mouseOverHandle in [self.__TLHandle,
379                                              self.__BRHandle]:
380                    self.setCursor(Qt.CursorShape.SizeFDiagCursor)
381                elif self.__mouseOverHandle in [self.__TRHandle,
382                                                self.__BLHandle]:
383                    self.setCursor(Qt.CursorShape.SizeBDiagCursor)
384                elif self.__mouseOverHandle in [self.__LHandle,
385                                                self.__RHandle]:
386                    self.setCursor(Qt.CursorShape.SizeHorCursor)
387                elif self.__mouseOverHandle in [self.__THandle,
388                                                self.__BHandle]:
389                    self.setCursor(Qt.CursorShape.SizeVerCursor)
390
391    def mouseReleaseEvent(self, evt):
392        """
393        Protected method to handle mouse button releases.
394
395        @param evt mouse release event (QMouseEvent)
396        """
397        self.__mouseDown = False
398        self.__newSelection = False
399        if (
400            self.__mouseOverHandle is None and
401            self.__selection.contains(evt.pos())
402        ):
403            self.setCursor(Qt.CursorShape.OpenHandCursor)
404        self.update()
405
406    def mouseDoubleClickEvent(self, evt):
407        """
408        Protected method to handle mouse double clicks.
409
410        @param evt mouse double click event (QMouseEvent)
411        """
412        self.__grabRect()
413
414    def keyPressEvent(self, evt):
415        """
416        Protected method to handle key presses.
417
418        @param evt key press event (QKeyEvent)
419        """
420        if evt.key() == Qt.Key.Key_Escape:
421            self.grabbed.emit(QPixmap())
422        elif evt.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return]:
423            self.__grabRect()
424        else:
425            evt.ignore()
426
427    def __updateHandles(self):
428        """
429        Private method to update the handles.
430        """
431        r = QRect(self.__selection)
432        s2 = self.__handleSize // 2
433
434        self.__TLHandle.moveTopLeft(r.topLeft())
435        self.__TRHandle.moveTopRight(r.topRight())
436        self.__BLHandle.moveBottomLeft(r.bottomLeft())
437        self.__BRHandle.moveBottomRight(r.bottomRight())
438
439        self.__LHandle.moveTopLeft(
440            QPoint(r.x(), r.y() + r.height() // 2 - s2))
441        self.__THandle.moveTopLeft(
442            QPoint(r.x() + r.width() // 2 - s2, r.y()))
443        self.__RHandle.moveTopRight(
444            QPoint(r.right(), r.y() + r.height() // 2 - s2))
445        self.__BHandle.moveBottomLeft(
446            QPoint(r.x() + r.width() // 2 - s2, r.bottom()))
447
448    def __handleMask(self, maskType):
449        """
450        Private method to calculate the handle mask.
451
452        @param maskType type of the mask to be used
453            (SnapshotRegionGrabber.FillMask or
454            SnapshotRegionGrabber.StrokeMask)
455        @return calculated mask (QRegion)
456        """
457        mask = QRegion()
458        for rect in self.__handles:
459            if maskType == SnapshotRegionGrabber.StrokeMask:
460                r = QRegion(rect)
461                mask += r.subtracted(QRegion(rect.adjusted(1, 1, -1, -1)))
462            else:
463                mask += QRegion(rect.adjusted(1, 1, -1, -1))
464        return mask
465
466    def __limitPointToRect(self, point, rect):
467        """
468        Private method to limit the given point to the given rectangle.
469
470        @param point point to be limited (QPoint)
471        @param rect rectangle the point shall be limited to (QRect)
472        @return limited point (QPoint)
473        """
474        q = QPoint()
475        if point.x() < rect.x():
476            q.setX(rect.x())
477        elif point.x() < rect.right():
478            q.setX(point.x())
479        else:
480            q.setX(rect.right())
481        if point.y() < rect.y():
482            q.setY(rect.y())
483        elif point.y() < rect.bottom():
484            q.setY(point.y())
485        else:
486            q.setY(rect.bottom())
487        return q
488
489    def __normalizeSelection(self, sel):
490        """
491        Private method to normalize the given selection.
492
493        @param sel selection to be normalized (QRect)
494        @return normalized selection (QRect)
495        """
496        rect = QRect(sel)
497        if rect.width() <= 0:
498            left = rect.left()
499            width = rect.width()
500            rect.setLeft(left + width - 1)
501            rect.setRight(left)
502        if rect.height() <= 0:
503            top = rect.top()
504            height = rect.height()
505            rect.setTop(top + height - 1)
506            rect.setBottom(top)
507        return rect
508
509    def __grabRect(self):
510        """
511        Private method to grab the selected rectangle (i.e. do the snapshot).
512        """
513        if self.__mode == SnapshotRegionGrabber.Ellipse:
514            ell = QRegion(self.__selection, QRegion.RegionType.Ellipse)
515            if not ell.isEmpty():
516                self.__grabbing = True
517
518                xOffset = self.__pixmap.rect().x() - ell.boundingRect().x()
519                yOffset = self.__pixmap.rect().y() - ell.boundingRect().y()
520                translatedEll = ell.translated(xOffset, yOffset)
521
522                pixmap2 = QPixmap(ell.boundingRect().size())
523                pixmap2.fill(Qt.GlobalColor.transparent)
524
525                pt = QPainter()
526                pt.begin(pixmap2)
527                if pt.paintEngine().hasFeature(
528                    QPaintEngine.PaintEngineFeature.PorterDuff
529                ):
530                    pt.setRenderHints(
531                        QPainter.RenderHint.Antialiasing |
532                        QPainter.RenderHint.HighQualityAntialiasing |
533                        QPainter.RenderHint.SmoothPixmapTransform,
534                        True)
535                    pt.setBrush(Qt.GlobalColor.black)
536                    pt.setPen(QPen(QBrush(Qt.GlobalColor.black), 0.5))
537                    pt.drawEllipse(translatedEll.boundingRect())
538                    pt.setCompositionMode(
539                        QPainter.CompositionMode.CompositionMode_SourceIn)
540                else:
541                    pt.setClipRegion(translatedEll)
542                    pt.setCompositionMode(
543                        QPainter.CompositionMode.CompositionMode_Source)
544
545                pt.drawPixmap(pixmap2.rect(), self.__pixmap,
546                              ell.boundingRect())
547                pt.end()
548
549                self.grabbed.emit(pixmap2)
550        else:
551            r = QRect(self.__selection)
552            if not r.isNull() and r.isValid():
553                self.__grabbing = True
554                self.grabbed.emit(self.__pixmap.copy(r))
555