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