1import sys 2import enum 3import base64 4from itertools import chain 5from operator import attrgetter 6from xml.sax.saxutils import escape 7from collections import OrderedDict 8from typing import ( 9 NamedTuple, Tuple, List, Dict, Iterable, Union, Optional, Hashable 10) 11 12from AnyQt.QtCore import Qt, QSize, QBuffer, QPropertyAnimation, QEasingCurve, Property 13from AnyQt.QtGui import ( 14 QIcon, QPixmap, QPainter, QPalette, QLinearGradient, QBrush, QPen 15) 16from AnyQt.QtWidgets import ( 17 QWidget, QLabel, QSizePolicy, QStyle, QHBoxLayout, QMessageBox, 18 QMenu, QWidgetAction, QStyleOption, QStylePainter, QApplication 19) 20from AnyQt.QtCore import pyqtSignal as Signal 21 22__all__ = ["Message", "MessagesWidget"] 23 24 25def image_data(pm): 26 # type: (QPixmap) -> str 27 """ 28 Render the contents of the pixmap as a data URL (RFC-2397) 29 30 Parameters 31 ---------- 32 pm : QPixmap 33 34 Returns 35 ------- 36 datauri : str 37 """ 38 pm = QPixmap(pm) 39 device = QBuffer() 40 assert device.open(QBuffer.ReadWrite) 41 pm.save(device, b'png') 42 device.close() 43 data = bytes(device.data()) 44 payload = base64.b64encode(data).decode("ascii") 45 return "data:image/png;base64," + payload 46 47 48class Severity(enum.IntEnum): 49 """ 50 An enum defining a severity level. 51 """ 52 #: General informative message. 53 Information = QMessageBox.Information 54 #: A warning message severity. 55 Warning = QMessageBox.Warning 56 #: An error message severity. 57 Error = QMessageBox.Critical 58 59 60class Message( 61 NamedTuple( 62 "Message", [ 63 ("severity", Severity), 64 ("icon", QIcon), 65 ("text", str), 66 ("informativeText", str), 67 ("detailedText", str), 68 ("textFormat", Qt.TextFormat) 69 ])): 70 """ 71 A stateful message/notification. 72 73 Parameters 74 ---------- 75 severity : `Severity` 76 Severity level (default: :attr:`Severity.Information`). 77 icon : QIcon 78 Associated icon. If empty the `QStyle.standardIcon` will be used based 79 on severity. 80 text : str 81 Short message text. 82 informativeText : str 83 Extra informative text to append to `text` (space permitting). 84 detailedText : str 85 Extra detailed text (e.g. exception traceback) 86 textFormat : Qt.TextFormat 87 If `Qt.RichText` then the contents of `text`, `informativeText` and 88 `detailedText` will be rendered as html instead of plain text. 89 90 """ 91 #: Alias for :class:`.Severity` 92 Severity = Severity 93 94 #: Alias for :attr:`Severity.Information` 95 Information = Severity.Information 96 #: Alias for :attr:`Severity.Warning` 97 Warning = Severity.Warning 98 #: Alias for :attr:`Severity.Error` 99 Error = Severity.Error 100 101 def __new__(cls, severity=Severity.Information, icon=QIcon(), text="", 102 informativeText="", detailedText="", textFormat=Qt.PlainText): 103 return super().__new__(cls, Severity(severity), QIcon(icon), text, 104 informativeText, detailedText, textFormat) 105 106 def asHtml(self, includeShortText=True): 107 # type: () -> str 108 """ 109 Render the message as an HTML fragment. 110 """ 111 if self.textFormat == Qt.RichText: 112 render = lambda t: t 113 else: 114 render = lambda t: ('<span style="white-space: pre">{}</span>' 115 .format(escape(t))) 116 117 def iconsrc(message, size=12): 118 # type: (Message) -> str 119 """ 120 Return an image src url for message icon. 121 """ 122 icon = message_icon(message) 123 pm = icon.pixmap(size, size) 124 return image_data(pm) 125 126 imgsize = 12 127 parts = [ 128 ('<div class="message {}">' 129 .format(self.severity.name.lower())) 130 ] 131 if includeShortText: 132 parts += [('<div class="field-text">' 133 '<img src="{iconurl}" width="{imgsize}" height="{imgsize}" />' 134 ' {text}' 135 '</div>' 136 .format(iconurl=iconsrc(self, size=imgsize * 2), 137 imgsize=imgsize, 138 text=render(self.text)))] 139 if self.informativeText: 140 parts += ['<div class="field-informative-text">{}</div>' 141 .format(render(self.informativeText))] 142 if self.detailedText: 143 parts += ['<div class="field-detailed-text">{}</div>' 144 .format(render(self.detailedText))] 145 parts += ['</div>'] 146 return "\n".join(parts) 147 148 def isEmpty(self): 149 # type: () -> bool 150 """ 151 Is this message instance empty (has no text or icon) 152 """ 153 return (not self.text and self.icon.isNull() and 154 not self.informativeText and not self.detailedText) 155 @property 156 def icon(self): 157 return QIcon(super().icon) 158 159 def __eq__(self, other): 160 if isinstance(other, Message): 161 return (self.severity == other.severity and 162 self.icon.cacheKey() == other.icon.cacheKey() and 163 self.text == other.text and 164 self.informativeText == other.informativeText and 165 self.detailedText == other.detailedText and 166 self.textFormat == other.textFormat) 167 else: 168 return False 169 170 171def standard_pixmap(severity): 172 # type: (Severity) -> QStyle.StandardPixmap 173 mapping = { 174 Severity.Information: QStyle.SP_MessageBoxInformation, 175 Severity.Warning: QStyle.SP_MessageBoxWarning, 176 Severity.Error: QStyle.SP_MessageBoxCritical, 177 } 178 return mapping[severity] 179 180 181def message_icon(message, style=None): 182 # type: (Message, Optional[QStyle]) -> QIcon 183 """ 184 Return the resolved icon for the message. 185 186 If `message.icon` is a valid icon then it is used. Otherwise the 187 appropriate style icon is used based on the `message.severity` 188 189 Parameters 190 ---------- 191 message : Message 192 style : Optional[QStyle] 193 194 Returns 195 ------- 196 icon : QIcon 197 """ 198 if style is None and QApplication.instance() is not None: 199 style = QApplication.style() 200 if message.icon.isNull(): 201 icon = style.standardIcon(standard_pixmap(message.severity)) 202 else: 203 icon = message.icon 204 return icon 205 206 207def categorize(messages): 208 # type: (List[Message]) -> Tuple[Optional[Message], List[Message], List[Message], List[Message]] 209 """ 210 Categorize the messages by severity picking the message leader if 211 possible. 212 213 The leader is a message with the highest severity iff it is the only 214 representative of that severity. 215 216 Parameters 217 ---------- 218 messages : List[Messages] 219 220 Returns 221 ------- 222 r : Tuple[Optional[Message], List[Message], List[Message], List[Message]] 223 """ 224 errors = [m for m in messages if m.severity == Severity.Error] 225 warnings = [m for m in messages if m.severity == Severity.Warning] 226 info = [m for m in messages if m.severity == Severity.Information] 227 lead = None 228 if len(errors) == 1: 229 lead = errors.pop(-1) 230 elif not errors and len(warnings) == 1: 231 lead = warnings.pop(-1) 232 elif not errors and not warnings and len(info) == 1: 233 lead = info.pop(-1) 234 return lead, errors, warnings, info 235 236 237# pylint: disable=too-many-branches 238def summarize(messages): 239 # type: (List[Message]) -> Message 240 """ 241 Summarize a list of messages into a single message instance 242 243 Parameters 244 ---------- 245 messages: List[Message] 246 247 Returns 248 ------- 249 message: Message 250 """ 251 if not messages: 252 return Message() 253 254 if len(messages) == 1: 255 return messages[0] 256 257 lead, errors, warnings, info = categorize(messages) 258 severity = Severity.Information 259 icon = QIcon() 260 leading_text = "" 261 text_parts = [] 262 if lead is not None: 263 severity = lead.severity 264 icon = lead.icon 265 leading_text = lead.text 266 elif errors: 267 severity = Severity.Error 268 elif warnings: 269 severity = Severity.Warning 270 271 def format_plural(fstr, items, *args, **kwargs): 272 return fstr.format(len(items), *args, 273 s="s" if len(items) != 1 else "", 274 **kwargs) 275 if errors: 276 text_parts.append(format_plural("{} error{s}", errors)) 277 if warnings: 278 text_parts.append(format_plural("{} warning{s}", warnings)) 279 if info: 280 if not (errors and warnings and lead): 281 text_parts.append(format_plural("{} message{s}", info)) 282 else: 283 text_parts.append(format_plural("{} other", info)) 284 285 if leading_text: 286 text = leading_text 287 if text_parts: 288 text = text + " (" + ", ".join(text_parts) + ")" 289 else: 290 text = ", ".join(text_parts) 291 detailed = "<hr/>".join(m.asHtml() 292 for m in chain([lead], errors, warnings, info) 293 if m is not None and not m.isEmpty()) 294 return Message(severity, icon, text, detailedText=detailed, 295 textFormat=Qt.RichText) 296 297 298class ElidingLabel(QLabel): 299 def __init__(self, elide=False, **kwargs): 300 super().__init__(**kwargs) 301 self.__elide = elide 302 self.__originalText = "" 303 304 def resizeEvent(self, event): 305 if self.__elide: 306 self.__setElidedText(self.__originalText) 307 308 def __setElidedText(self, text): 309 fm = self.fontMetrics() 310 311 # Qt sometimes elides even when text width == target width 312 width = self.width() + 1 313 314 elided = fm.elidedText(text, Qt.ElideRight, width) 315 super().setText(elided) 316 317 def setText(self, text): 318 self.__originalText = text 319 if self.__elide: 320 self.__setElidedText(text) 321 else: 322 super().setText(text) 323 324 def sizeHint(self): 325 fm = self.fontMetrics() 326 w = fm.width(self.__originalText) 327 h = super().minimumSizeHint().height() 328 329 return QSize(w, h) 330 331 def setElide(self, enabled): 332 if self.__elide == enabled: 333 return 334 335 self.__elide = enabled 336 if enabled: 337 self.__setElidedText(self.__originalText) 338 else: 339 super().setText(self.__originalText) 340 341 342class MessagesWidget(QWidget): 343 """ 344 An iconified multiple message display area. 345 346 `MessagesWidget` displays a short message along with an icon. If there 347 are multiple messages they are summarized. The user can click on the 348 widget to display the full message text in a popup view. 349 """ 350 #: Signal emitted when an embedded html link is clicked 351 #: (if `openExternalLinks` is `False`). 352 linkActivated = Signal(str) 353 354 #: Signal emitted when an embedded html link is hovered. 355 linkHovered = Signal(str) 356 357 Severity = Severity 358 #: General informative message. 359 Information = Severity.Information 360 #: A warning message severity. 361 Warning = Severity.Warning 362 #: An error message severity. 363 Error = Severity.Error 364 365 Message = Message 366 367 def __init__(self, parent=None, openExternalLinks=False, elideText=False, 368 defaultStyleSheet="", **kwargs): 369 kwargs.setdefault( 370 "sizePolicy", 371 QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 372 ) 373 super().__init__(parent, **kwargs) 374 self.__openExternalLinks = openExternalLinks # type: bool 375 self.__messages = OrderedDict() # type: Dict[Hashable, Message] 376 #: The full (joined all messages text - rendered as html), displayed 377 #: in a tooltip. 378 self.__fulltext = "" 379 #: Leading icon 380 self.__iconwidget = IconWidget( 381 sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) 382 ) 383 #: Inline message text 384 self.__textlabel = ElidingLabel( 385 wordWrap=False, 386 textInteractionFlags=Qt.LinksAccessibleByMouse, 387 openExternalLinks=self.__openExternalLinks, 388 sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum), 389 elide=elideText 390 ) 391 self.__textlabel.linkActivated.connect(self.linkActivated) 392 self.__textlabel.linkHovered.connect(self.linkHovered) 393 self.setLayout(QHBoxLayout()) 394 self.layout().setContentsMargins(2, 1, 2, 1) 395 self.layout().setSpacing(0) 396 self.layout().addWidget(self.__iconwidget, alignment=Qt.AlignLeft) 397 self.layout().addSpacing(4) 398 self.layout().addWidget(self.__textlabel) 399 self.__textlabel.setAttribute(Qt.WA_MacSmallSize) 400 self.__defaultStyleSheet = defaultStyleSheet 401 402 self.anim = QPropertyAnimation( 403 self.__iconwidget, b"opacity", self.__iconwidget) 404 self.anim.setDuration(350) 405 self.anim.setStartValue(1) 406 self.anim.setKeyValueAt(0.5, 0) 407 self.anim.setEndValue(1) 408 self.anim.setEasingCurve(QEasingCurve.OutQuad) 409 self.anim.setLoopCount(2) 410 411 def sizeHint(self): 412 sh = super().sizeHint() 413 h = self.style().pixelMetric(QStyle.PM_SmallIconSize) 414 if all(m.isEmpty() for m in self.messages()): 415 sh.setWidth(0) 416 return sh.expandedTo(QSize(0, h + 2)) 417 418 def minimumSizeHint(self): 419 msh = super().minimumSizeHint() 420 h = self.style().pixelMetric(QStyle.PM_SmallIconSize) 421 if all(m.isEmpty() for m in self.messages()): 422 msh.setWidth(0) 423 else: 424 msh.setWidth(h + 2) 425 return msh.expandedTo(QSize(0, h + 2)) 426 427 def setOpenExternalLinks(self, state): 428 # type: (bool) -> None 429 """ 430 If `True` then `linkActivated` signal will be emitted when the user 431 clicks on an html link in a message, otherwise links are opened 432 using `QDesktopServices.openUrl` 433 """ 434 # TODO: update popup if open 435 self.__openExternalLinks = state 436 self.__textlabel.setOpenExternalLinks(state) 437 438 def openExternalLinks(self): 439 # type: () -> bool 440 """ 441 """ 442 return self.__openExternalLinks 443 444 def setDefaultStyleSheet(self, css): 445 # type: (str) -> None 446 """ 447 Set a default css to apply to the rendered text. 448 449 Parameters 450 ---------- 451 css : str 452 A css style sheet as supported by Qt's Rich Text support. 453 454 Note 455 ---- 456 Not to be confused with `QWidget.styleSheet` 457 458 See Also 459 -------- 460 `Supported HTML Subset`_ 461 462 .. _`Supported HTML Subset`: 463 http://doc.qt.io/qt-5/richtext-html-subset.html 464 """ 465 if self.__defaultStyleSheet != css: 466 self.__defaultStyleSheet = css 467 self.__update() 468 469 def defaultStyleSheet(self): 470 """ 471 Returns 472 ------- 473 css : str 474 The current style sheet 475 """ 476 return self.__defaultStyleSheet 477 478 def setMessage(self, message_id, message): 479 # type: (Hashable, Message) -> None 480 """ 481 Add a `message` for `message_id` to the current display. 482 483 Note 484 ---- 485 Set an empty `Message` instance to clear the message display but 486 retain the relative ordering in the display should a message for 487 `message_id` reactivate. 488 """ 489 self.__messages[message_id] = message 490 self.__update() 491 492 def removeMessage(self, message_id): 493 # type: (Hashable) -> None 494 """ 495 Remove message for `message_id` from the display. 496 497 Note 498 ---- 499 Setting an empty `Message` instance will also clear the display, 500 however the relative ordering of the messages will be retained, 501 should the `message_id` 'reactivate'. 502 """ 503 del self.__messages[message_id] 504 self.__update() 505 506 def setMessages(self, messages): 507 # type: (Union[Iterable[Tuple[Hashable, Message]], Dict[Hashable, Message]]) -> None 508 """ 509 Set multiple messages in a single call. 510 """ 511 messages = OrderedDict(messages) 512 self.__messages.update(messages) 513 self.__update() 514 515 def clear(self): 516 # type: () -> None 517 """ 518 Clear all messages. 519 """ 520 self.__messages.clear() 521 self.__update() 522 523 def messages(self): 524 # type: () -> List[Message] 525 """ 526 Return all set messages. 527 528 Returns 529 ------- 530 messages: `List[Message]` 531 """ 532 return list(self.__messages.values()) 533 534 def summarize(self): 535 # type: () -> Message 536 """ 537 Summarize all the messages into a single message. 538 """ 539 messages = [m for m in self.__messages.values() if not m.isEmpty()] 540 if messages: 541 return summarize(messages) 542 else: 543 return Message() 544 545 def flashIcon(self): 546 for message in self.messages(): 547 if message.severity != Severity.Information: 548 self.anim.start(QPropertyAnimation.KeepWhenStopped) 549 break 550 551 @staticmethod 552 def __styled(css, html): 553 # Prepend css style sheet before a html fragment. 554 if css.strip(): 555 return "<style>\n" + escape(css) + "\n</style>\n" + html 556 else: 557 return html 558 559 def __update(self): 560 """ 561 Update the current display state. 562 """ 563 self.ensurePolished() 564 summary = self.summarize() 565 icon = message_icon(summary) 566 self.__iconwidget.setIcon(icon) 567 self.__iconwidget.setVisible(not (summary.isEmpty() or icon.isNull())) 568 self.__textlabel.setTextFormat(summary.textFormat) 569 self.__textlabel.setText(summary.text) 570 self.__textlabel.setVisible(bool(summary.text)) 571 572 def is_short(m): 573 return not (m.informativeText or m.detailedText) 574 575 messages = [m for m in self.__messages.values() if not m.isEmpty()] 576 if not messages: 577 fulltext = "" 578 else: 579 messages = sorted(messages, key=attrgetter("severity"), 580 reverse=True) 581 fulltext = "<hr/>".join(m.asHtml() for m in messages) 582 self.__fulltext = fulltext 583 self.setToolTip(self.__styled(self.__defaultStyleSheet, fulltext)) 584 self.anim.start(QPropertyAnimation.KeepWhenStopped) 585 586 self.layout().activate() 587 588 def mousePressEvent(self, event): 589 if event.button() == Qt.LeftButton: 590 if self.__fulltext: 591 popup = QMenu(self) 592 label = QLabel( 593 self, textInteractionFlags=Qt.TextBrowserInteraction, 594 openExternalLinks=self.__openExternalLinks, 595 ) 596 label.setContentsMargins(4, 4, 4, 4) 597 label.setText(self.__styled(self.__defaultStyleSheet, 598 self.__fulltext)) 599 600 label.linkActivated.connect(self.linkActivated) 601 label.linkHovered.connect(self.linkHovered) 602 action = QWidgetAction(popup) 603 action.setDefaultWidget(label) 604 popup.addAction(action) 605 popup.popup(event.globalPos(), action) 606 event.accept() 607 return 608 else: 609 super().mousePressEvent(event) 610 611 def enterEvent(self, event): 612 super().enterEvent(event) 613 self.update() 614 615 def leaveEvent(self, event): 616 super().leaveEvent(event) 617 self.update() 618 619 def changeEvent(self, event): 620 super().changeEvent(event) 621 self.update() 622 623 def paintEvent(self, event): 624 opt = QStyleOption() 625 opt.initFrom(self) 626 if not self.__fulltext: 627 return 628 629 if not (opt.state & QStyle.State_MouseOver or 630 opt.state & QStyle.State_HasFocus): 631 return 632 633 palette = opt.palette # type: QPalette 634 if opt.state & QStyle.State_HasFocus: 635 pen = QPen(palette.color(QPalette.Highlight)) 636 else: 637 pen = QPen(palette.color(QPalette.Dark)) 638 639 if self.__fulltext and \ 640 opt.state & QStyle.State_MouseOver and \ 641 opt.state & QStyle.State_Active: 642 g = QLinearGradient() 643 g.setCoordinateMode(QLinearGradient.ObjectBoundingMode) 644 base = palette.color(QPalette.Window) 645 base.setAlpha(90) 646 g.setColorAt(0, base.lighter(200)) 647 g.setColorAt(0.6, base) 648 g.setColorAt(1.0, base.lighter(200)) 649 brush = QBrush(g) 650 else: 651 brush = QBrush(Qt.NoBrush) 652 p = QPainter(self) 653 p.setBrush(brush) 654 p.setPen(pen) 655 p.drawRect(opt.rect.adjusted(0, 0, -1, -1)) 656 657 658class IconWidget(QWidget): 659 """ 660 A widget displaying an `QIcon` 661 """ 662 def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs): 663 sizePolicy = kwargs.pop("sizePolicy", QSizePolicy(QSizePolicy.Fixed, 664 QSizePolicy.Fixed)) 665 super().__init__(parent, **kwargs) 666 self._opacity = 1 667 self.__icon = QIcon(icon) 668 self.__iconSize = QSize(iconSize) 669 self.setSizePolicy(sizePolicy) 670 671 def setIcon(self, icon): 672 # type: (QIcon) -> None 673 if self.__icon != icon: 674 self.__icon = QIcon(icon) 675 self.updateGeometry() 676 self.update() 677 678 def getOpacity(self): 679 return self._opacity 680 681 def setOpacity(self, o): 682 self._opacity = o 683 self.update() 684 685 opacity = Property(float, fget=getOpacity, fset=setOpacity) 686 687 def icon(self): 688 # type: () -> QIcon 689 return QIcon(self.__icon) 690 691 def iconSize(self): 692 # type: () -> QSize 693 if not self.__iconSize.isValid(): 694 size = self.style().pixelMetric(QStyle.PM_ButtonIconSize) 695 return QSize(size, size) 696 else: 697 return QSize(self.__iconSize) 698 699 def setIconSize(self, iconSize): 700 # type: (QSize) -> None 701 if self.__iconSize != iconSize: 702 self.__iconSize = QSize(iconSize) 703 self.updateGeometry() 704 self.update() 705 706 def sizeHint(self): 707 sh = self.iconSize() 708 m = self.contentsMargins() 709 return QSize(sh.width() + m.left() + m.right(), 710 sh.height() + m.top() + m.bottom()) 711 712 def paintEvent(self, event): 713 painter = QStylePainter(self) 714 painter.setOpacity(self._opacity) 715 opt = QStyleOption() 716 opt.initFrom(self) 717 painter.drawPrimitive(QStyle.PE_Widget, opt) 718 if not self.__icon.isNull(): 719 rect = self.contentsRect() 720 if opt.state & QStyle.State_Active: 721 mode = QIcon.Active 722 else: 723 mode = QIcon.Disabled 724 self.__icon.paint(painter, rect, Qt.AlignCenter, mode, QIcon.Off) 725 painter.end() 726 727 728def main(argv=None): # pragma: no cover 729 from AnyQt.QtWidgets import QVBoxLayout, QCheckBox, QStatusBar 730 app = QApplication(list(argv) if argv else []) 731 l1 = QVBoxLayout() 732 l1.setContentsMargins(0, 0, 0, 0) 733 blayout = QVBoxLayout() 734 l1.addLayout(blayout) 735 sb = QStatusBar() 736 737 w = QWidget() 738 w.setLayout(l1) 739 messages = [ 740 Message(Severity.Error, text="Encountered a HCF", 741 detailedText="<em>AAA! It burns.</em>", 742 textFormat=Qt.RichText), 743 Message(Severity.Warning, 744 text="ACHTUNG!", 745 detailedText=( 746 "<div style=\"color: red\">DAS KOMPUTERMASCHINE IST " 747 "NICHT FÜR DER GEFINGERPOKEN</div>" 748 ), 749 textFormat=Qt.RichText), 750 Message(Severity.Information, 751 text="The rain in spain falls mostly on the plain", 752 informativeText=( 753 "<a href=\"https://www.google.si/search?q=" 754 "Average+Yearly+Precipitation+in+Spain\">Link</a>" 755 ), 756 textFormat=Qt.RichText), 757 Message(Severity.Error, 758 text="I did not do this!", 759 informativeText="The computer made suggestions...", 760 detailedText="... and the default options was yes."), 761 Message(), 762 ] 763 mw = MessagesWidget(openExternalLinks=True) 764 for i, m in enumerate(messages): 765 cb = QCheckBox(m.text) 766 767 def toogled(state, i=i, m=m): 768 if state: 769 mw.setMessage(i, m) 770 else: 771 mw.removeMessage(i) 772 cb.toggled[bool].connect(toogled) 773 blayout.addWidget(cb) 774 775 sb.addWidget(mw) 776 w.layout().addWidget(sb, 0) 777 w.show() 778 return app.exec_() 779 780if __name__ == "__main__": # pragma: no cover 781 sys.exit(main(sys.argv)) 782