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