1""" 2Helper utilities 3 4""" 5import sys 6import traceback 7import ctypes 8 9from contextlib import contextmanager 10from typing import Optional, Union 11 12from AnyQt.QtWidgets import ( 13 QWidget, QMessageBox, QStyleOption, QStyle, QTextEdit, QScrollBar 14) 15from AnyQt.QtGui import ( 16 QGradient, QLinearGradient, QRadialGradient, QBrush, QPainter, 17 QPaintEvent, QColor, QPixmap, QPixmapCache, QTextOption, QGuiApplication, 18 QTextCharFormat, QFont 19) 20from AnyQt.QtCore import Qt, QPointF, QPoint, QRect, QRectF, Signal, QEvent 21 22import sip 23 24 25@contextmanager 26def updates_disabled(widget): 27 """Disable QWidget updates (using QWidget.setUpdatesEnabled) 28 """ 29 old_state = widget.updatesEnabled() 30 widget.setUpdatesEnabled(False) 31 try: 32 yield 33 finally: 34 widget.setUpdatesEnabled(old_state) 35 36 37@contextmanager 38def signals_disabled(qobject): 39 """Disables signals on an instance of QObject. 40 """ 41 old_state = qobject.signalsBlocked() 42 qobject.blockSignals(True) 43 try: 44 yield 45 finally: 46 qobject.blockSignals(old_state) 47 48 49@contextmanager 50def disabled(qobject): 51 """Disables a disablable QObject instance. 52 """ 53 if not (hasattr(qobject, "setEnabled") and hasattr(qobject, "isEnabled")): 54 raise TypeError("%r does not have 'enabled' property" % qobject) 55 56 old_state = qobject.isEnabled() 57 qobject.setEnabled(False) 58 try: 59 yield 60 finally: 61 qobject.setEnabled(old_state) 62 63 64@contextmanager 65def disconnected(signal, slot, type=Qt.UniqueConnection): 66 """ 67 A context manager disconnecting a slot from a signal. 68 :: 69 70 with disconnected(scene.selectionChanged, self.onSelectionChanged): 71 # Can change item selection in a scene without 72 # onSelectionChanged being invoked. 73 do_something() 74 75 Warning 76 ------- 77 The relative order of the slot in signal's connections is not preserved. 78 79 Raises 80 ------ 81 TypeError: 82 If the slot was not connected to the signal 83 """ 84 signal.disconnect(slot) 85 try: 86 yield 87 finally: 88 signal.connect(slot, type) 89 90 91def StyledWidget_paintEvent(self, event): 92 # type: (QWidget, QPaintEvent) -> None 93 """A default styled QWidget subclass paintEvent function. 94 """ 95 opt = QStyleOption() 96 opt.initFrom(self) 97 painter = QPainter(self) 98 self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) 99 100 101class StyledWidget(QWidget): 102 """ 103 """ 104 paintEvent = StyledWidget_paintEvent # type: ignore 105 106 107class ScrollBar(QScrollBar): 108 #: Emitted when the scroll bar receives a StyleChange event 109 styleChange = Signal() 110 111 def changeEvent(self, event: QEvent) -> None: 112 if event.type() == QEvent.StyleChange: 113 self.styleChange.emit() 114 super().changeEvent(event) 115 116 117def is_transparency_supported(): # type: () -> bool 118 """Is window transparency supported by the current windowing system. 119 """ 120 if sys.platform == "win32": 121 return is_dwm_compositing_enabled() 122 elif sys.platform == "cygwin": 123 return False 124 elif sys.platform == "darwin": 125 if has_x11(): 126 return is_x11_compositing_enabled() 127 else: 128 # Quartz compositor 129 return True 130 elif sys.platform.startswith("linux"): 131 # TODO: wayland?? 132 return is_x11_compositing_enabled() 133 elif sys.platform.startswith("freebsd"): 134 return is_x11_compositing_enabled() 135 elif has_x11(): 136 return is_x11_compositing_enabled() 137 else: 138 return False 139 140 141def has_x11(): # type: () -> bool 142 """Is Qt build against X11 server. 143 """ 144 try: 145 from AnyQt.QtX11Extras import QX11Info 146 return True 147 except ImportError: 148 return False 149 150 151def is_x11_compositing_enabled(): # type: () -> bool 152 """Is X11 compositing manager running. 153 """ 154 try: 155 from AnyQt.QtX11Extras import QX11Info 156 except ImportError: 157 return False 158 if hasattr(QX11Info, "isCompositingManagerRunning"): 159 return QX11Info.isCompositingManagerRunning() 160 else: 161 # not available on Qt5 162 return False # ? 163 164 165def is_dwm_compositing_enabled(): # type: () -> bool 166 """Is Desktop Window Manager compositing (Aero) enabled. 167 """ 168 enabled = ctypes.c_bool(False) 169 try: 170 DwmIsCompositionEnabled = \ 171 ctypes.windll.dwmapi.DwmIsCompositionEnabled # type: ignore 172 except (AttributeError, WindowsError): 173 # dwmapi or DwmIsCompositionEnabled is not present 174 return False 175 176 rval = DwmIsCompositionEnabled(ctypes.byref(enabled)) 177 178 return rval == 0 and enabled.value 179 180 181def gradient_darker(grad, factor): 182 # type: (QGradient, float) -> QGradient 183 """Return a copy of the QGradient darkened by factor. 184 185 .. note:: Only QLinearGradeint and QRadialGradient are supported. 186 187 """ 188 if type(grad) is QGradient: 189 if grad.type() == QGradient.LinearGradient: 190 grad = sip.cast(grad, QLinearGradient) 191 elif grad.type() == QGradient.RadialGradient: 192 grad = sip.cast(grad, QRadialGradient) 193 194 if isinstance(grad, QLinearGradient): 195 new_grad = QLinearGradient(grad.start(), grad.finalStop()) 196 elif isinstance(grad, QRadialGradient): 197 new_grad = QRadialGradient(grad.center(), grad.radius(), 198 grad.focalPoint()) 199 else: 200 raise TypeError 201 202 new_grad.setCoordinateMode(grad.coordinateMode()) 203 204 for pos, color in grad.stops(): 205 new_grad.setColorAt(pos, color.darker(factor)) 206 207 return new_grad 208 209 210def brush_darker(brush: QBrush, factor: bool) -> QBrush: 211 """Return a copy of the brush darkened by factor. 212 """ 213 grad = brush.gradient() 214 if grad: 215 return QBrush(gradient_darker(grad, factor)) 216 else: 217 brush = QBrush(brush) 218 brush.setColor(brush.color().darker(factor)) 219 return brush 220 221 222def create_gradient(base_color: QColor, stop=QPointF(0, 0), 223 finalStop=QPointF(0, 1)) -> QLinearGradient: 224 """ 225 Create a default linear gradient using `base_color` . 226 """ 227 grad = QLinearGradient(stop, finalStop) 228 grad.setStops([(0.0, base_color), 229 (0.5, base_color), 230 (0.8, base_color.darker(105)), 231 (1.0, base_color.darker(110)), 232 ]) 233 grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode) 234 return grad 235 236 237def create_css_gradient(base_color: QColor, stop=QPointF(0, 0), 238 finalStop=QPointF(0, 1)) -> str: 239 """ 240 Create a Qt css linear gradient fragment based on the `base_color`. 241 """ 242 gradient = create_gradient(base_color, stop, finalStop) 243 return css_gradient(gradient) 244 245 246def css_gradient(gradient: QLinearGradient) -> str: 247 """ 248 Given an instance of a `QLinearGradient` return an equivalent qt css 249 gradient fragment. 250 """ 251 stop, finalStop = gradient.start(), gradient.finalStop() 252 x1, y1, x2, y2 = stop.x(), stop.y(), finalStop.x(), finalStop.y() 253 stops = gradient.stops() 254 stops = "\n".join(" stop: {0:f} {1}".format(stop, color.name()) 255 for stop, color in stops) 256 return ("qlineargradient(\n" 257 " x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2},\n" 258 "{stops})").format(x1=x1, y1=y1, x2=x2, y2=y2, stops=stops) 259 260 261def message_critical(text, title=None, informative_text=None, details=None, 262 buttons=None, default_button=None, exc_info=False, 263 parent=None): 264 """Show a critical message. 265 """ 266 if not text: 267 text = "An unexpected error occurred." 268 269 if title is None: 270 title = "Error" 271 272 return message(QMessageBox.Critical, text, title, informative_text, 273 details, buttons, default_button, exc_info, parent) 274 275 276def message_warning(text, title=None, informative_text=None, details=None, 277 buttons=None, default_button=None, exc_info=False, 278 parent=None): 279 """Show a warning message. 280 """ 281 if not text: 282 import random 283 text_candidates = ["Death could come at any moment.", 284 "Murphy lurks about. Remember to save frequently." 285 ] 286 text = random.choice(text_candidates) 287 288 if title is not None: 289 title = "Warning" 290 291 return message(QMessageBox.Warning, text, title, informative_text, 292 details, buttons, default_button, exc_info, parent) 293 294 295def message_information(text, title=None, informative_text=None, details=None, 296 buttons=None, default_button=None, exc_info=False, 297 parent=None): 298 """Show an information message box. 299 """ 300 if title is None: 301 title = "Information" 302 if not text: 303 text = "I am not a number." 304 305 return message(QMessageBox.Information, text, title, informative_text, 306 details, buttons, default_button, exc_info, parent) 307 308 309def message_question(text, title, informative_text=None, details=None, 310 buttons=None, default_button=None, exc_info=False, 311 parent=None): 312 """Show an message box asking the user to select some 313 predefined course of action (set by buttons argument). 314 315 """ 316 return message(QMessageBox.Question, text, title, informative_text, 317 details, buttons, default_button, exc_info, parent) 318 319 320def message(icon, text, title=None, informative_text=None, details=None, 321 buttons=None, default_button=None, exc_info=False, parent=None): 322 """Show a message helper function. 323 """ 324 if title is None: 325 title = "Message" 326 if not text: 327 text = "I am neither a postman nor a doctor." 328 329 if buttons is None: 330 buttons = QMessageBox.Ok 331 332 if details is None and exc_info: 333 details = traceback.format_exc(limit=20) 334 335 mbox = QMessageBox(icon, title, text, buttons, parent) 336 337 if informative_text: 338 mbox.setInformativeText(informative_text) 339 340 if details: 341 mbox.setDetailedText(details) 342 dtextedit = mbox.findChild(QTextEdit) 343 if dtextedit is not None: 344 dtextedit.setWordWrapMode(QTextOption.NoWrap) 345 346 if default_button is not None: 347 mbox.setDefaultButton(default_button) 348 349 return mbox.exec_() 350 351 352def innerGlowBackgroundPixmap(color, size, radius=5): 353 """ Draws radial gradient pixmap, then uses that to draw 354 a rounded-corner gradient rectangle pixmap. 355 356 Args: 357 color (QColor): used as outer color (lightness 245 used for inner) 358 size (QSize): size of output pixmap 359 radius (int): radius of inner glow rounded corners 360 """ 361 key = "InnerGlowBackground " + \ 362 color.name() + " " + \ 363 str(radius) 364 365 bg = QPixmapCache.find(key) 366 if bg: 367 return bg 368 369 # set background colors for gradient 370 color = color.toHsl() 371 light_color = color.fromHsl(color.hslHue(), color.hslSaturation(), 245) 372 dark_color = color 373 374 # initialize radial gradient 375 center = QPoint(radius, radius) 376 pixRect = QRect(0, 0, radius * 2, radius * 2) 377 gradientPixmap = QPixmap(radius * 2, radius * 2) 378 gradientPixmap.fill(dark_color) 379 380 # draw radial gradient pixmap 381 pixPainter = QPainter(gradientPixmap) 382 pixPainter.setPen(Qt.NoPen) 383 gradient = QRadialGradient(center, radius - 1) 384 gradient.setColorAt(0, light_color) 385 gradient.setColorAt(1, dark_color) 386 pixPainter.setBrush(gradient) 387 pixPainter.drawRect(pixRect) 388 pixPainter.end() 389 390 # set tl and br to the gradient's square-shaped rect 391 tl = QPoint(0, 0) 392 br = QPoint(size.width(), size.height()) 393 394 # fragments of radial gradient pixmap to create rounded gradient outline rectangle 395 frags = [ 396 # top-left corner 397 QPainter.PixmapFragment.create( 398 QPointF(tl.x() + radius / 2, tl.y() + radius / 2), 399 QRectF(0, 0, radius, radius) 400 ), 401 # top-mid 'linear gradient' 402 QPainter.PixmapFragment.create( 403 QPointF(tl.x() + (br.x() - tl.x()) / 2, tl.y() + radius / 2), 404 QRectF(radius, 0, 1, radius), 405 scaleX=(br.x() - tl.x() - 2 * radius) 406 ), 407 # top-right corner 408 QPainter.PixmapFragment.create( 409 QPointF(br.x() - radius / 2, tl.y() + radius / 2), 410 QRectF(radius, 0, radius, radius) 411 ), 412 # left-mid 'linear gradient' 413 QPainter.PixmapFragment.create( 414 QPointF(tl.x() + radius / 2, tl.y() + (br.y() - tl.y()) / 2), 415 QRectF(0, radius, radius, 1), 416 scaleY=(br.y() - tl.y() - 2 * radius) 417 ), 418 # mid solid 419 QPainter.PixmapFragment.create( 420 QPointF(tl.x() + (br.x() - tl.x()) / 2, tl.y() + (br.y() - tl.y()) / 2), 421 QRectF(radius, radius, 1, 1), 422 scaleX=(br.x() - tl.x() - 2 * radius), 423 scaleY=(br.y() - tl.y() - 2 * radius) 424 ), 425 # right-mid 'linear gradient' 426 QPainter.PixmapFragment.create( 427 QPointF(br.x() - radius / 2, tl.y() + (br.y() - tl.y()) / 2), 428 QRectF(radius, radius, radius, 1), 429 scaleY=(br.y() - tl.y() - 2 * radius) 430 ), 431 # bottom-left corner 432 QPainter.PixmapFragment.create( 433 QPointF(tl.x() + radius / 2, br.y() - radius / 2), 434 QRectF(0, radius, radius, radius) 435 ), 436 # bottom-mid 'linear gradient' 437 QPainter.PixmapFragment.create( 438 QPointF(tl.x() + (br.x() - tl.x()) / 2, br.y() - radius / 2), 439 QRectF(radius, radius, 1, radius), 440 scaleX=(br.x() - tl.x() - 2 * radius) 441 ), 442 # bottom-right corner 443 QPainter.PixmapFragment.create( 444 QPointF(br.x() - radius / 2, br.y() - radius / 2), 445 QRectF(radius, radius, radius, radius) 446 ), 447 ] 448 449 # draw icon background to pixmap 450 outPix = QPixmap(size.width(), size.height()) 451 outPainter = QPainter(outPix) 452 outPainter.setPen(Qt.NoPen) 453 outPainter.drawPixmapFragments(frags, 454 gradientPixmap, 455 QPainter.PixmapFragmentHints(QPainter.OpaqueHint)) 456 outPainter.end() 457 458 QPixmapCache.insert(key, outPix) 459 460 return outPix 461 462 463def shadowTemplatePixmap(color, length): 464 """ 465 Returns 1 pixel wide, `length` pixels long linear-gradient. 466 467 Args: 468 color (QColor): shadow color 469 length (int): length of cast shadow 470 471 """ 472 key = "InnerShadowTemplate " + \ 473 color.name() + " " + \ 474 str(length) 475 476 # get cached template 477 shadowPixmap = QPixmapCache.find(key) 478 if shadowPixmap: 479 return shadowPixmap 480 481 shadowPixmap = QPixmap(1, length) 482 shadowPixmap.fill(Qt.transparent) 483 484 grad = QLinearGradient(0, 0, 0, length) 485 grad.setColorAt(0, color) 486 grad.setColorAt(1, Qt.transparent) 487 488 painter = QPainter() 489 painter.begin(shadowPixmap) 490 painter.fillRect(shadowPixmap.rect(), grad) 491 painter.end() 492 493 # cache template 494 QPixmapCache.insert(key, shadowPixmap) 495 496 return shadowPixmap 497 498 499def innerShadowPixmap(color, size, pos, length=5): 500 """ 501 Args: 502 color (QColor): shadow color 503 size (QSize): size of pixmap 504 pos (int): shadow position int flag, use bitwise operations 505 1 - top 506 2 - right 507 4 - bottom 508 8 - left 509 length (int): length of cast shadow 510 """ 511 key = "InnerShadow " + \ 512 color.name() + " " + \ 513 str(size) + " " + \ 514 str(pos) + " " + \ 515 str(length) 516 # get cached shadow if it exists 517 finalShadow = QPixmapCache.find(key) 518 if finalShadow: 519 return finalShadow 520 521 shadowTemplate = shadowTemplatePixmap(color, length) 522 523 finalShadow = QPixmap(size) 524 finalShadow.fill(Qt.transparent) 525 shadowPainter = QPainter(finalShadow) 526 shadowPainter.setCompositionMode(QPainter.CompositionMode_Darken) 527 528 # top/bottom rect 529 targetRect = QRect(0, 0, size.width(), length) 530 531 # shadow on top 532 if pos & 1: 533 shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) 534 # shadow on bottom 535 if pos & 4: 536 shadowPainter.save() 537 538 shadowPainter.translate(QPointF(0, size.height())) 539 shadowPainter.scale(1, -1) 540 shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) 541 542 shadowPainter.restore() 543 544 # left/right rect 545 targetRect = QRect(0, 0, size.height(), shadowTemplate.rect().height()) 546 547 # shadow on the right 548 if pos & 2: 549 shadowPainter.save() 550 551 shadowPainter.translate(QPointF(size.width(), 0)) 552 shadowPainter.rotate(90) 553 shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) 554 555 shadowPainter.restore() 556 # shadow on left 557 if pos & 8: 558 shadowPainter.save() 559 560 shadowPainter.translate(0, size.height()) 561 shadowPainter.rotate(-90) 562 shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) 563 564 shadowPainter.restore() 565 566 shadowPainter.end() 567 568 # cache shadow 569 QPixmapCache.insert(key, finalShadow) 570 571 return finalShadow 572 573 574def clipboard_has_format(mimetype): 575 # type: (str) -> bool 576 """Does the system clipboard contain data for mimetype?""" 577 cb = QGuiApplication.clipboard() 578 if cb is None: 579 return False 580 mime = cb.mimeData() 581 if mime is None: 582 return False 583 return mime.hasFormat(mimetype) 584 585 586def clipboard_data(mimetype: str) -> Optional[bytes]: 587 """Return the binary data of the system clipboard for mimetype.""" 588 cb = QGuiApplication.clipboard() 589 if cb is None: 590 return None 591 mime = cb.mimeData() 592 if mime is None: 593 return None 594 if mime.hasFormat(mimetype): 595 return bytes(mime.data(mimetype)) 596 else: 597 return None 598 599 600_Color = Union[QColor, QBrush, Qt.GlobalColor, QGradient] 601 602 603def update_char_format( 604 baseformat: QTextCharFormat, 605 color: Optional[_Color] = None, 606 background: Optional[_Color] = None, 607 weight: Optional[int] = None, 608 italic: Optional[bool] = None, 609 underline: Optional[bool] = None, 610 font: Optional[QFont] = None 611) -> QTextCharFormat: 612 """ 613 Return a copy of `baseformat` :class:`QTextCharFormat` with 614 updated color, weight, background and font properties. 615 """ 616 charformat = QTextCharFormat(baseformat) 617 if color is not None: 618 charformat.setForeground(color) 619 if background is not None: 620 charformat.setBackground(background) 621 if font is not None: 622 assert weight is None and italic is None and underline is None 623 charformat.setFont(font) 624 else: 625 if weight is not None: 626 charformat.setFontWeight(weight) 627 if italic is not None: 628 charformat.setFontItalic(italic) 629 if underline is not None: 630 charformat.setFontUnderline(underline) 631 return charformat 632 633 634def update_font( 635 basefont: QFont, 636 weight: Optional[int] = None, 637 italic: Optional[bool] = None, 638 underline: Optional[bool] = None, 639 pixelSize: Optional[int] = None, 640 pointSize: Optional[float] = None 641) -> QFont: 642 """ 643 Return a copy of `basefont` :class:`QFont` with updated properties. 644 """ 645 font = QFont(basefont) 646 647 if weight is not None: 648 font.setWeight(weight) 649 650 if italic is not None: 651 font.setItalic(italic) 652 653 if underline is not None: 654 font.setUnderline(underline) 655 656 if pixelSize is not None: 657 font.setPixelSize(pixelSize) 658 659 if pointSize is not None: 660 font.setPointSizeF(pointSize) 661 662 return font 663