diff --git a/Orange/widgets/tests/test_widget.py b/Orange/widgets/tests/test_widget.py index 47012cacf64..e512c05a33d 100644 --- a/Orange/widgets/tests/test_widget.py +++ b/Orange/widgets/tests/test_widget.py @@ -124,6 +124,27 @@ def test_widget_emits_messages(self): w.Information.hello.clear() self.assertEqual(len(messages), 0) + def test_message_exc_info(self): + w = WidgetMsgTestCase.TestWidget() + w.Error.add_message("error") + messages = set([]) + w.messageActivated.connect(messages.add) + w.messageDeactivated.connect(messages.remove) + try: + _ = 1 / 0 + except ZeroDivisionError: + w.Error.error("AA", exc_info=True) + + self.assertEqual(len(messages), 1) + m = list(messages).pop() + self.assertIsNotNone(m.tb) + self.assertIn("ZeroDivisionError", m.tb) + + w.Error.error("BB", exc_info=Exception("foobar")) + self.assertIn("foobar", m.tb) + w.Error.error("BB") + self.assertIsNone(m.tb) + def test_old_style_messages(self): w = WidgetMsgTestCase.TestWidget() w.Information.clear() diff --git a/Orange/widgets/utils/messages.py b/Orange/widgets/utils/messages.py index d7109f59c9e..2ae079854a6 100644 --- a/Orange/widgets/utils/messages.py +++ b/Orange/widgets/utils/messages.py @@ -27,14 +27,18 @@ Clearing messages work analogously. """ - +import sys +import traceback from operator import attrgetter from warnings import warn from inspect import getattr_static +# pylint: disable=unused-import +from typing import Optional -from AnyQt.QtWidgets import QApplication, QStyle, QSizePolicy +from AnyQt.QtWidgets import QStyle, QSizePolicy from Orange.widgets import gui +from Orange.widgets.utils.messagewidget import MessagesWidget class UnboundMsg(str): @@ -53,7 +57,7 @@ def bind(self, group): # The method is implemented in _BoundMsg # pylint: disable=unused-variable - def __call__(self, *args, shown=True, **kwargs): + def __call__(self, *args, shown=True, exc_info=None, **kwargs): """ Show the message, or hide it if `show` is set `False` `*args` and `**kwargs` are passed to the `format` method. @@ -61,6 +65,10 @@ def __call__(self, *args, shown=True, **kwargs): Args: shown (bool): keyword-only argument that can be set to `False` to hide the message + exc_info (Union[BaseException, bool, None]): Optional exception + instance whose traceback to store in the message. Can also be + a `True` value in which case the exception is retrieved from + sys.exc_info() *args: arguments for `format` **kwargs: keyword arguments for `format` """ @@ -105,13 +113,23 @@ def __new__(cls, unbound_msg, group): self = UnboundMsg.__new__(cls, unbound_msg) self.group = group self.formatted = "" + self.tb = None # type: Optional[str] return self - def __call__(self, *args, shown=True, **kwargs): + def __call__(self, *args, shown=True, exc_info=None, **kwargs): + self.tb = None if not shown: self.clear() else: self.formatted = self.format(*args, **kwargs) + if exc_info: + if isinstance(exc_info, BaseException): + exc_info = (type(exc_info), exc_info, + exc_info.__traceback__) + elif not isinstance(exc_info, tuple): + exc_info = sys.exc_info() + if exc_info is not None: + self.tb = "".join(traceback.format_exception(*exc_info)) self.group.activate_msg(self) def clear(self): @@ -311,7 +329,7 @@ class Information(MessageGroup): def __init__(self): super().__init__() - self.message_bar = self.message_label = self.message_icon = None + self.message_bar = None self.messageActivated.connect(self.update_message_state) self.messageDeactivated.connect(self.update_message_state) @@ -326,73 +344,41 @@ def update_message_state(self): The method is connected to widget's signals `messageActivated` and `messageDeactivated`. """ + if self.message_bar is None: + return + assert isinstance(self.message_bar, MessagesWidget) + + def msg(m): + # type: (_BoundMsg) -> MessagesWidget.Message + text = str(m) + extra = "" + if "\n" in text: + text, extra = text.split("\n", 1) + + return MessagesWidget.Message( + MessagesWidget.Severity(m.group.severity), + text=text, informativeText=extra, + detailedText=m.tb if m.tb else "" + ) + messages = [msg for group in self.message_groups for msg in group.active] - if not messages: - self._hide_message_bar() - return - elif self.message_bar is not None: - font_size = self.message_bar.fontInfo().pixelSize() - group = messages[0].group - text = str(messages[0]).split("\n")[0] if len(messages) == 1 \ - else "{} problems during execution".format(len(messages)) - # TODO: fix tooltip background color - it is not white - tooltip = ''.join( - '''

-
-     - {} -     -
-

'''. - format(msg.group.bar_background, font_size, - str(msg).replace("\n", "
    ")) - for msg in messages) - self._set_message_bar(group, text, tooltip) + + self.message_bar.clear() + if messages: + self.message_bar.setMessages((m, msg(m)) for m in messages) def insert_message_bar(self): """Insert message bar into the widget. This method must be called at the appropriate place in the widget layout setup by any widget that is using this mixin.""" - self.message_bar = gui.hBox(self, spacing=0) - self.message_icon = gui.widgetLabel(self.message_bar, "") - self.message_label = gui.widgetLabel(self.message_bar, "") - self.message_label.setStyleSheet("padding-top: 5px") + self.message_bar = MessagesWidget(self) self.message_bar.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Maximum) - gui.rubber(self.message_bar) + self.layout().addWidget(self.message_bar) self.message_bar.setVisible(False) - def _hide_message_bar(self): - if self.message_bar is None: - return - - if not self.message_bar.isHidden(): - new_height = self.height() - self.message_bar.height() - self.message_bar.setVisible(False) - self.resize(self.width(), new_height) - - def _set_message_bar(self, group, text=None, tooltip=None): - if self.message_bar is None: - return - - current_height = self.height() - style = QApplication.instance().style() - self.message_icon.setPixmap( - style.standardIcon(group.bar_icon).pixmap(14, 14)) - self.message_bar.setStyleSheet( - "QWidget {{ background-color: {}; color: black;" - "padding: 3px; padding-left: 6px; vertical-align: center }}\n" - "QToolTip {{ background-color: white; }}". - format(group.bar_background)) - self.message_label.setText(text) - self.message_bar.setToolTip(tooltip) - if self.message_bar.isHidden(): - self.message_bar.setVisible(True) - new_height = current_height + self.message_bar.height() - self.resize(self.width(), new_height) - # pylint doesn't know that Information, Error and Warning are instantiated # and thus the methods are bound # pylint: disable=no-value-for-parameter diff --git a/Orange/widgets/utils/messagewidget.py b/Orange/widgets/utils/messagewidget.py new file mode 100644 index 00000000000..462b8edbfb1 --- /dev/null +++ b/Orange/widgets/utils/messagewidget.py @@ -0,0 +1,631 @@ +import sys +import enum +import base64 +from itertools import chain +from operator import attrgetter +from xml.sax.saxutils import escape +from collections import OrderedDict +# pylint: disable=unused-import +from typing import ( + NamedTuple, Tuple, List, Dict, Iterable, Union, Optional, Hashable +) + +from AnyQt.QtCore import Qt, QSize, QBuffer +from AnyQt.QtGui import ( + QIcon, QPixmap, QPainter, QPalette, QLinearGradient, QBrush, QPen +) +from AnyQt.QtWidgets import ( + QWidget, QLabel, QSizePolicy, QStyle, QHBoxLayout, QMessageBox, + QMenu, QWidgetAction, QStyleOption, QStylePainter, QApplication +) +from AnyQt.QtCore import pyqtSignal as Signal + +__all__ = ["Message", "MessagesWidget"] + + +def image_data(pm): + # type: (QPixmap) -> str + """ + Render the contents of the pixmap as a data URL (RFC-2397) + + Parameters + ---------- + pm : QPixmap + + Returns + ------- + datauri : str + """ + pm = QPixmap(pm) + device = QBuffer() + assert device.open(QBuffer.ReadWrite) + pm.save(device, b'png') + device.close() + data = bytes(device.data()) + payload = base64.b64encode(data).decode("ascii") + return "data:image/png;base64," + payload + + +class Severity(enum.IntEnum): + """ + Message severity level. + """ + Information = QMessageBox.Information + Warning = QMessageBox.Warning + Error = QMessageBox.Critical + + +class Message( + NamedTuple( + "Message", [ + ("severity", Severity), + ("icon", QIcon), + ("text", str), + ("informativeText", str), + ("detailedText", str), + ("textFormat", Qt.TextFormat) + ])): + """ + A stateful message/notification. + + Parameters + ---------- + severity : Message.Severity + Severity level (default: Information). + icon : QIcon + Associated icon. If empty the `QStyle.standardIcon` will be used based + on severity. + text : str + Short message text. + informativeText : str + Extra informative text to append to `text` (space permitting). + detailedText : str + Extra detailed text (e.g. exception traceback) + textFormat : Qt.TextFormat + If `Qt.RichText` then the contents of `text`, `informativeText` and + `detailedText` will be rendered as html instead of plain text. + + """ + Severity = Severity + Warning = Severity.Warning + Information = Severity.Information + Error = Severity.Error + + def __new__(cls, severity=Severity.Information, icon=QIcon(), text="", + informativeText="", detailedText="", textFormat=Qt.PlainText): + return super().__new__(cls, Severity(severity), icon, text, + informativeText, detailedText, textFormat) + + def asHtml(self): + # type: () -> str + """ + Render the message as an HTML fragment. + """ + if self.textFormat == Qt.RichText: + render = lambda t: t + else: + render = lambda t: ('{}' + .format(escape(t))) + + def iconsrc(message): + # type: (Message) -> str + """ + Return an image src url for message icon. + """ + icon = message_icon(message) + pm = icon.pixmap(12, 12) + return image_data(pm) + + parts = [ + ('
' + .format(self.severity.name.lower())), + ('
' + '{text}
' + .format(iconurl=iconsrc(self), text=render(self.text))) + ] + if self.informativeText: + parts += ['
{}
' + .format(render(self.informativeText))] + if self.detailedText: + parts += ['
{}
' + .format(render(self.detailedText))] + parts += ['
'] + return "\n".join(parts) + + def isEmpty(self): + # type: () -> bool + """ + Is this message instance empty (has no text or icon) + """ + return (not self.text and self.icon.isNull() and + not self.informativeText and not self.detailedText) + + +def standard_pixmap(severity): + # type: (Severity) -> QStyle.StandardPixmap + mapping = { + Severity.Information: QStyle.SP_MessageBoxInformation, + Severity.Warning: QStyle.SP_MessageBoxWarning, + Severity.Error: QStyle.SP_MessageBoxCritical, + } + return mapping[severity] + + +def message_icon(message, style=None): + # type: (Message, Optional[QStyle]) -> QIcon + """ + Return the resolved icon for the message. + + If `message.icon` is a valid icon then it is used. Otherwise the + appropriate style icon is used based on the `message.severity` + + Parameters + ---------- + message : Message + style : Optional[QStyle] + + Returns + ------- + icon : QIcon + """ + if style is None and QApplication.instance() is not None: + style = QApplication.style() + if message.icon.isNull(): + icon = style.standardIcon(standard_pixmap(message.severity)) + else: + icon = message.icon + return icon + + +def categorize(messages): + # type: (List[Message]) -> Tuple[Optional[Message], List[Message], List[Message], List[Message]] + """ + Categorize the messages by severity picking the message leader if + possible. + + The leader is a message with the highest severity iff it is the only + representative of that severity. + + Parameters + ---------- + messages : List[Messages] + + Returns + ------- + r : Tuple[Optional[Message], List[Message], List[Message], List[Message]] + """ + errors = [m for m in messages if m.severity == Severity.Error] + warnings = [m for m in messages if m.severity == Severity.Warning] + info = [m for m in messages if m.severity == Severity.Information] + lead = None + if len(errors) == 1: + lead = errors.pop(-1) + elif not errors and len(warnings) == 1: + lead = warnings.pop(-1) + elif not errors and not warnings and len(info) == 1: + lead = info.pop(-1) + return lead, errors, warnings, info + + +# pylint: disable=too-many-branches +def summarize(messages): + # type: (List[Message]) -> Message + """ + Summarize a list of messages into a single message instance + + Parameters + ---------- + messages: List[Message] + + Returns + ------- + message: Message + """ + if not messages: + return Message() + + if len(messages) == 1: + return messages[0] + + lead, errors, warnings, info = categorize(messages) + severity = Severity.Information + icon = QIcon() + leading_text = "" + text_parts = [] + if lead is not None: + severity = lead.severity + icon = lead.icon + leading_text = lead.text + elif errors: + severity = Severity.Error + elif warnings: + severity = Severity.Warning + + def format_plural(fstr, items, *args, **kwargs): + return fstr.format(len(items), *args, + s="s" if len(items) != 1 else "", + **kwargs) + if errors: + text_parts.append(format_plural("{} error{s}", errors)) + if warnings: + text_parts.append(format_plural("{} warning{s}", warnings)) + if info: + if not (errors and warnings and lead): + text_parts.append(format_plural("{} message{s}", info)) + else: + text_parts.append(format_plural("{} other", info)) + + if leading_text: + text = leading_text + if text_parts: + text = text + " (" + ", ".join(text_parts) + ")" + else: + text = ", ".join(text_parts) + detailed = "
".join(m.asHtml() + for m in chain([lead], errors, warnings, info) + if m is not None and not m.isEmpty()) + return Message(severity, icon, text, detailedText=detailed, + textFormat=Qt.RichText) + + +class MessagesWidget(QWidget): + """ + An iconified multiple message display area. + + `MessagesWidget` displays a short message along with an icon. If there + are multiple messages they are summarized. The user can click on the + widget to display the full message text in a popup view. + """ + #: Signal emitted when an embedded html link is clicked + #: (if `openExternalLinks` is `False`). + linkActivated = Signal(str) + + #: Signal emitted when an embedded html link is hovered. + linkHovered = Signal(str) + + Severity = Severity + #: General informative message. + Information = Severity.Information + #: A warning message severity. + Warning = Severity.Warning + #: An error message severity. + Error = Severity.Error + + Message = Message + + def __init__(self, parent=None, openExternalLinks=False, **kwargs): + kwargs.setdefault( + "sizePolicy", + QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + ) + super().__init__(parent, **kwargs) + self.__openExternalLinks = openExternalLinks # type: bool + self.__messages = OrderedDict() # type: Dict[Hashable, Message] + #: The full (joined all messages text - rendered as html), displayed + #: in a tooltip. + self.__fulltext = "" + #: The full text displayed in a popup. Is empty if the message is + #: short + self.__popuptext = "" + #: Leading icon + self.__iconwidget = IconWidget( + sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + #: Inline message text + self.__textlabel = QLabel( + wordWrap=False, + textInteractionFlags=Qt.LinksAccessibleByMouse, + openExternalLinks=self.__openExternalLinks, + sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + ) + #: Indicator that extended contents are accessible with a click on the + #: widget. + self.__popupicon = QLabel( + sizePolicy=QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum), + text="\N{VERTICAL ELLIPSIS}", + visible=False, + ) + self.__textlabel.linkActivated.connect(self.linkActivated) + self.__textlabel.linkHovered.connect(self.linkHovered) + self.setLayout(QHBoxLayout()) + self.layout().setContentsMargins(2, 1, 2, 1) + self.layout().setSpacing(0) + self.layout().addWidget(self.__iconwidget) + self.layout().addSpacing(4) + self.layout().addWidget(self.__textlabel) + self.layout().addWidget(self.__popupicon) + self.__textlabel.setAttribute(Qt.WA_MacSmallSize) + + def sizeHint(self): + sh = super().sizeHint() + h = self.style().pixelMetric(QStyle.PM_SmallIconSize) + return sh.expandedTo(QSize(0, h + 2)) + + def openExternalLinks(self): + # type: () -> bool + """ + If True then linkActivated signal will be emitted when the user + clicks on an html link in a message, otherwise links are opened + using `QDesktopServices.openUrl` + """ + return self.__openExternalLinks + + def setOpenExternalLinks(self, state): + # type: (bool) -> None + """ + """ + # TODO: update popup if open + self.__openExternalLinks = state + self.__textlabel.setOpenExternalLinks(state) + + def setMessage(self, message_id, message): + # type: (Hashable, Message) -> None + """ + Add a `message` for `message_id` to the current display. + + Note + ---- + Set an empty `Message` instance to clear the message display but + retain the relative ordering in the display should a message for + `message_id` reactivate. + """ + self.__messages[message_id] = message + self.__update() + + def removeMessage(self, message_id): + # type: (Hashable) -> None + """ + Remove message for `message_id` from the display. + + Note + ---- + Setting an empty `Message` instance will also clear the display, + however the relative ordering of the messages will be retained, + should the `message_id` 'reactivate'. + """ + del self.__messages[message_id] + self.__update() + + def setMessages(self, messages): + # type: (Union[Iterable[Tuple[Hashable, Message]], Dict[Hashable, Message]]) -> None + """ + Set multiple messages in a single call. + """ + messages = OrderedDict(messages) + self.__messages.update(messages) + self.__update() + + def clear(self): + # type: () -> None + """ + Clear all messages. + """ + self.__messages.clear() + self.__update() + + def messages(self): + # type: () -> List[Message] + return list(self.__messages.values()) + + def summarize(self): + # type: () -> Message + """ + Summarize all the messages into a single message. + """ + messages = [m for m in self.__messages.values() if not m.isEmpty()] + if messages: + return summarize(messages) + else: + return Message() + + def __update(self): + """ + Update the current display state. + """ + self.ensurePolished() + summary = self.summarize() + icon = message_icon(summary) + self.__iconwidget.setIcon(icon) + self.__iconwidget.setVisible(not (summary.isEmpty() or icon.isNull())) + self.__textlabel.setTextFormat(summary.textFormat) + self.__textlabel.setText(summary.text) + messages = [m for m in self.__messages.values() if not m.isEmpty()] + if messages: + messages = sorted(messages, key=attrgetter("severity"), + reverse=True) + fulltext = "
".join(m.asHtml() for m in messages) + else: + fulltext = "" + self.__fulltext = fulltext + self.setToolTip(fulltext) + + def is_short(m): + return not (m.informativeText or m.detailedText) + + if not messages or len(messages) == 1 and is_short(messages[0]): + self.__popuptext = "" + else: + self.__popuptext = fulltext + self.__popupicon.setVisible(bool(self.__popuptext)) + self.layout().activate() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + if self.__popuptext: + popup = QMenu(self) + label = QLabel( + self, textInteractionFlags=Qt.TextBrowserInteraction, + openExternalLinks=self.__openExternalLinks, + text=self.__popuptext + ) + label.linkActivated.connect(self.linkActivated) + label.linkHovered.connect(self.linkHovered) + action = QWidgetAction(popup) + action.setDefaultWidget(label) + popup.addAction(action) + popup.popup(event.globalPos(), action) + event.accept() + return + else: + super().mousePressEvent(event) + + def enterEvent(self, event): + super().enterEvent(event) + self.update() + + def leaveEvent(self, event): + super().leaveEvent(event) + self.update() + + def changeEvent(self, event): + super().changeEvent(event) + self.update() + + def paintEvent(self, event): + opt = QStyleOption() + opt.initFrom(self) + if not self.__popupicon.isVisible(): + return + + if not (opt.state & QStyle.State_MouseOver or + opt.state & QStyle.State_HasFocus): + return + + palette = opt.palette # type: QPalette + if opt.state & QStyle.State_HasFocus: + pen = QPen(palette.color(QPalette.Highlight)) + else: + pen = QPen(palette.color(QPalette.Dark)) + + if self.__fulltext and \ + opt.state & QStyle.State_MouseOver and \ + opt.state & QStyle.State_Active: + g = QLinearGradient() + g.setCoordinateMode(QLinearGradient.ObjectBoundingMode) + base = palette.color(QPalette.Window) + base.setAlpha(90) + g.setColorAt(0, base.lighter(200)) + g.setColorAt(0.6, base) + g.setColorAt(1.0, base.lighter(200)) + brush = QBrush(g) + else: + brush = QBrush(Qt.NoBrush) + p = QPainter(self) + p.setBrush(brush) + p.setPen(pen) + p.drawRect(opt.rect.adjusted(0, 0, -1, -1)) + + +class IconWidget(QWidget): + """ + A widget displaying an `QIcon` + """ + def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs): + sizePolicy = kwargs.pop("sizePolicy", QSizePolicy(QSizePolicy.Fixed, + QSizePolicy.Fixed)) + super().__init__(parent, **kwargs) + self.__icon = QIcon(icon) + self.__iconSize = QSize(iconSize) + self.setSizePolicy(sizePolicy) + + def setIcon(self, icon): + # type: (QIcon) -> None + if self.__icon != icon: + self.__icon = QIcon(icon) + self.updateGeometry() + self.update() + + def icon(self): + # type: () -> QIcon + return QIcon(self.__icon) + + def iconSize(self): + # type: () -> QSize + if not self.__iconSize.isValid(): + size = self.style().pixelMetric(QStyle.PM_ButtonIconSize) + return QSize(size, size) + else: + return QSize(self.__iconSize) + + def setIconSize(self, iconSize): + # type: (QSize) -> None + if self.__iconSize != iconSize: + self.__iconSize = QSize(iconSize) + self.updateGeometry() + self.update() + + def sizeHint(self): + sh = self.iconSize() + m = self.contentsMargins() + return QSize(sh.width() + m.left() + m.right(), + sh.height() + m.top() + m.bottom()) + + def paintEvent(self, event): + painter = QStylePainter(self) + opt = QStyleOption() + opt.initFrom(self) + painter.drawPrimitive(QStyle.PE_Widget, opt) + if not self.__icon.isNull(): + rect = self.contentsRect() + if opt.state & QStyle.State_Active: + mode = QIcon.Active + else: + mode = QIcon.Disabled + self.__icon.paint(painter, rect, Qt.AlignCenter, mode, QIcon.Off) + painter.end() + + +def main(argv=None): # pragma: no cover + from AnyQt.QtWidgets import QVBoxLayout, QCheckBox, QStatusBar + app = QApplication(list(argv) if argv else []) + l1 = QVBoxLayout() + l1.setContentsMargins(0, 0, 0, 0) + blayout = QVBoxLayout() + l1.addLayout(blayout) + sb = QStatusBar() + + w = QWidget() + w.setLayout(l1) + messages = [ + Message(Severity.Error, text="Encountered a HCF", + detailedText="AAA! It burns.", + textFormat=Qt.RichText), + Message(Severity.Warning, + text="ACHTUNG!", + detailedText=( + "
DAS KOMPUTERMASCHINE IST " + "NICHT FÜR DER GEFINGERPOKEN
" + ), + textFormat=Qt.RichText), + Message(Severity.Information, + text="The rain in spain falls mostly on the plain", + informativeText=( + "Link" + ), + textFormat=Qt.RichText), + Message(Severity.Error, + text="I did not do this!", + informativeText="The computer made suggestions...", + detailedText="... and the default options was yes."), + Message(), + ] + mw = MessagesWidget(openExternalLinks=True) + for i, m in enumerate(messages): + cb = QCheckBox(m.text) + + def toogled(state, i=i, m=m): + if state: + mw.setMessage(i, m) + else: + mw.removeMessage(i) + cb.toggled[bool].connect(toogled) + blayout.addWidget(cb) + + sb.addWidget(mw) + w.layout().addWidget(sb, 0) + w.show() + return app.exec_() + +if __name__ == "__main__": # pragma: no cover + sys.exit(main(sys.argv)) diff --git a/Orange/widgets/utils/overlay.py b/Orange/widgets/utils/overlay.py index 1931444fe78..53e575a8d01 100644 --- a/Orange/widgets/utils/overlay.py +++ b/Orange/widgets/utils/overlay.py @@ -2,7 +2,7 @@ Overlay Message Widget ---------------------- -A Widget to display a temporary dismissable message over another widget. +A Widget to display a temporary dismissible message over another widget. """ @@ -17,7 +17,7 @@ QStyleOptionButton, QStylePainter, QFocusFrame, QWidget, QStyleOption ) from AnyQt.QtGui import QIcon, QPixmap, QPainter -from AnyQt.QtCore import Qt, QSize, QRect, QPoint, QEvent, QTimer +from AnyQt.QtCore import Qt, QSize, QRect, QPoint, QEvent from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot @@ -102,8 +102,14 @@ def paintEvent(self, event): painter = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) + def showEvent(self, event): + super().showEvent(event) + # Force immediate re-layout on show + self.__layout() + def __layout(self): # position itself over `widget` + # pylint: disable=too-many-branches widget = self.__widget if widget is None: return @@ -111,17 +117,26 @@ def __layout(self): alignment = self.__alignment policy = self.sizePolicy() - if widget.isWindow(): - bounds = widget.geometry() + if widget.window() is self.window() and not self.isWindow(): + if widget.isWindow(): + bounds = widget.rect() + else: + bounds = QRect(widget.mapTo(widget.window(), QPoint(0, 0)), + widget.size()) + tl = self.parent().mapFrom(widget.window(), bounds.topLeft()) + bounds = QRect(tl, widget.size()) else: + if widget.isWindow(): + bounds = widget.geometry() + else: + bounds = QRect(widget.mapToGlobal(QPoint(0, 0)), + widget.size()) - bounds = QRect(widget.mapToGlobal(QPoint(0, 0)), - widget.size()) - if self.isWindow(): - bounds = bounds - else: - bounds = QRect(self.parent().mapFromGlobal(bounds.topLeft()), - bounds.size()) + if self.isWindow(): + bounds = bounds + else: + bounds = QRect(self.parent().mapFromGlobal(bounds.topLeft()), + bounds.size()) sh = self.sizeHint() minsh = self.minimumSizeHint() @@ -135,6 +150,10 @@ def __layout(self): hpolicy = policy.horizontalPolicy() vpolicy = policy.verticalPolicy() + if not effectivesh.isValid(): + effectivesh = QSize(0, 0) + vpolicy = hpolicy = QSizePolicy.Ignored + def getsize(hint, minimum, maximum, policy): if policy == QSizePolicy.Ignored: return maximum @@ -158,14 +177,14 @@ def getsize(hint, minimum, maximum, policy): if alignment & Qt.AlignLeft: x = bounds.x() elif alignment & Qt.AlignRight: - x = bounds.right() - size.width() + x = bounds.x() + bounds.width() - size.width() else: x = bounds.x() + max(0, bounds.width() - size.width()) // 2 if alignment & Qt.AlignTop: y = bounds.y() elif alignment & Qt.AlignBottom: - y = bounds.bottom() - size.height() + y = bounds.y() + bounds.height() - size.height() else: y = bounds.y() + max(0, bounds.height() - size.height()) // 2 @@ -459,7 +478,7 @@ def buttonRole(self, button): """ Return the ButtonRole for button - :type button: QAbsstractButton + :type button: QAbstractButton """ for slot in self.__buttons: if slot.button is button: @@ -591,51 +610,3 @@ def buttonRole(self, button): @proxydoc(MessageWidget.button) def button(self, standardButton): return self.__msgwidget.button(standardButton) - - -import unittest - - -class TestOverlay(unittest.TestCase): - def setUp(self): - from AnyQt.QtWidgets import QApplication - app = QApplication.instance() - if app is None: - app = QApplication([]) - self.app = app - - def _exec(self, timeout): - QTimer.singleShot(timeout, self.app.quit) - return self.app.exec_() - - def tearDown(self): - del self.app - - def test_overlay(self): - container = QWidget() - overlay = MessageOverlayWidget(parent=container) - overlay.setWidget(container) - overlay.setIcon(QStyle.SP_MessageBoxInformation) - container.show() - container.raise_() - self._exec(500) - self.assertTrue(overlay.isVisible()) - - overlay.setText("Hello world! It's so nice here") - self._exec(500) - button_ok = overlay.addButton(MessageOverlayWidget.Ok) - button_close = overlay.addButton(MessageOverlayWidget.Close) - button_help = overlay.addButton(MessageOverlayWidget.Help) - - self.assertTrue(all([button_ok, button_close, button_help])) - self.assertIs(overlay.button(MessageOverlayWidget.Ok), button_ok) - self.assertIs(overlay.button(MessageOverlayWidget.Close), button_close) - self.assertIs(overlay.button(MessageOverlayWidget.Help), button_help) - - button = overlay.addButton("Click Me!", - MessageOverlayWidget.AcceptRole) - self.assertIsNot(button, None) - self.assertTrue(overlay.buttonRole(button), - MessageOverlayWidget.AcceptRole) - - self._exec(10000) diff --git a/Orange/widgets/utils/tests/test_messagewidget.py b/Orange/widgets/utils/tests/test_messagewidget.py new file mode 100644 index 00000000000..ac650b86f47 --- /dev/null +++ b/Orange/widgets/utils/tests/test_messagewidget.py @@ -0,0 +1,54 @@ +from AnyQt.QtCore import Qt, QSize + +from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.messagewidget import ( + MessagesWidget, Message, IconWidget +) + + +class TestMessageWidget(GuiTest): + def test_widget(self): + w = MessagesWidget() + w.setMessage(0, Message()) + self.assertTrue(w.summarize().isEmpty()) + self.assertSequenceEqual(w.messages(), [Message()]) + w.setMessage(0, Message(Message.Warning, text="a")) + self.assertFalse(w.summarize().isEmpty()) + self.assertEqual(w.summarize().severity, Message.Warning) + self.assertEqual(w.summarize().text, "a") + w.setMessage(1, Message(Message.Error, text="#error#")) + self.assertEqual(w.summarize().severity, Message.Error) + self.assertTrue(w.summarize().text.startswith("#error#")) + self.assertSequenceEqual( + w.messages(), + [Message(Message.Warning, text="a"), + Message(Message.Error, text="#error#")]) + w.setMessage(2, Message(Message.Information, text="Hello", + textFormat=Qt.RichText)) + self.assertSequenceEqual( + w.messages(), + [Message(Message.Warning, text="a"), + Message(Message.Error, text="#error#"), + Message(Message.Information, text="Hello", + textFormat=Qt.RichText)]) + w.grab() + w.removeMessage(2) + w.clear() + w.setOpenExternalLinks(True) + assert w.openExternalLinks() + self.assertEqual(len(w.messages()), 0) + self.assertTrue(w.summarize().isEmpty()) + + +class TestIconWidget(GuiTest): + def test_widget(self): + w = IconWidget() + s = w.style() + icon = s.standardIcon(s.SP_BrowserStop) + w.setIcon(icon) + self.assertEqual(w.icon().cacheKey(), icon.cacheKey()) + w.setIconSize(QSize(42, 42)) + self.assertEqual(w.iconSize(), QSize(42, 42)) + self.assertGreaterEqual(w.sizeHint().width(), 42) + self.assertGreaterEqual(w.sizeHint().height(), 42) + w.setIconSize(QSize()) diff --git a/Orange/widgets/utils/tests/test_overlay.py b/Orange/widgets/utils/tests/test_overlay.py new file mode 100644 index 00000000000..f797e6f6fb7 --- /dev/null +++ b/Orange/widgets/utils/tests/test_overlay.py @@ -0,0 +1,86 @@ + +import unittest.mock +from AnyQt.QtCore import Qt, QEvent +from AnyQt.QtTest import QTest +from AnyQt.QtWidgets import QWidget, QHBoxLayout, QStyle, QApplication + +from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.overlay import ( + OverlayWidget, MessageOverlayWidget +) + +class TestOverlay(GuiTest): + def test_overlay_message(self): + container = QWidget() + overlay = MessageOverlayWidget(parent=container) + overlay.setWidget(container) + overlay.setIcon(QStyle.SP_MessageBoxInformation) + container.show() + QTest.qWaitForWindowExposed(container) + + self.assertTrue(overlay.isVisible()) + + overlay.setText("Hello world! It's so nice here") + QApplication.sendPostedEvents(overlay, QEvent.LayoutRequest) + self.assertTrue(overlay.geometry().isValid()) + + button_ok = overlay.addButton(MessageOverlayWidget.Ok) + button_close = overlay.addButton(MessageOverlayWidget.Close) + button_help = overlay.addButton(MessageOverlayWidget.Help) + + self.assertTrue(all([button_ok, button_close, button_help])) + self.assertIs(overlay.button(MessageOverlayWidget.Ok), button_ok) + self.assertIs(overlay.button(MessageOverlayWidget.Close), button_close) + self.assertIs(overlay.button(MessageOverlayWidget.Help), button_help) + + button = overlay.addButton("Click Me!", + MessageOverlayWidget.AcceptRole) + self.assertIsNot(button, None) + self.assertTrue(overlay.buttonRole(button), + MessageOverlayWidget.AcceptRole) + + mock = unittest.mock.MagicMock() + overlay.accepted.connect(mock) + QTest.mouseClick(button, Qt.LeftButton) + self.assertFalse(overlay.isVisible()) + + mock.assert_called_once_with() + + def test_layout(self): + container = QWidget() + container.setLayout(QHBoxLayout()) + container1 = QWidget() + container.layout().addWidget(container1) + container.show() + QTest.qWaitForWindowExposed(container) + container.resize(600, 600) + + overlay = OverlayWidget(parent=container) + overlay.setWidget(container) + overlay.resize(20, 20) + overlay.show() + + center = overlay.geometry().center() + self.assertTrue(290 < center.x() < 310) + self.assertTrue(290 < center.y() < 310) + + overlay.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + geom = overlay.geometry() + self.assertEqual(geom.top(), 0) + self.assertTrue(290 < geom.center().x() < 310) + + overlay.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + geom = overlay.geometry() + self.assertEqual(geom.left(), 0) + self.assertTrue(290 < geom.center().y() < 310) + + overlay.setAlignment(Qt.AlignBottom | Qt.AlignRight) + geom = overlay.geometry() + self.assertEqual(geom.right(), 600 - 1) + self.assertEqual(geom.bottom(), 600 - 1) + + overlay.setWidget(container1) + geom = overlay.geometry() + + self.assertEqual(geom.right(), container1.geometry().right()) + self.assertEqual(geom.bottom(), container1.geometry().bottom()) diff --git a/Orange/widgets/widget.py b/Orange/widgets/widget.py index df6f734d19e..1c5d7f79f89 100644 --- a/Orange/widgets/widget.py +++ b/Orange/widgets/widget.py @@ -7,10 +7,12 @@ from AnyQt.QtWidgets import ( QWidget, QDialog, QVBoxLayout, QSizePolicy, QApplication, QStyle, - QShortcut, QSplitter, QSplitterHandle, QPushButton + QShortcut, QSplitter, QSplitterHandle, QPushButton, QStatusBar, + QProgressBar ) from AnyQt.QtCore import ( - Qt, QByteArray, QSettings, QUrl, pyqtSignal as Signal) + Qt, QByteArray, QSettings, QUrl, pyqtSignal as Signal +) from AnyQt.QtGui import QIcon, QKeySequence, QDesktopServices from Orange.data import FileFormat @@ -26,10 +28,10 @@ from Orange.widgets.utils import saveplot, getdeepattr from Orange.widgets.utils.progressbar import ProgressBarMixin from Orange.widgets.utils.messages import \ - WidgetMessagesMixin, UnboundMsg + WidgetMessagesMixin, UnboundMsg, MessagesWidget from Orange.widgets.utils.signals import \ WidgetSignalsMixin, Input, Output, AttributeList -from .utils.overlay import MessageOverlayWidget +from Orange.widgets.utils.overlay import MessageOverlayWidget, OverlayWidget # Msg is imported and renamed, so widgets can import it from this module rather # than the one with the mixin (Orange.widgets.utils.messages). Assignment is @@ -323,21 +325,61 @@ def set_basic_layout(self): """ self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(2, 2, 2, 2) + if not self.resizing_enabled: self.layout().setSizeConstraint(QVBoxLayout.SetFixedSize) self.want_main_area = self.want_main_area or self.graph_name self._create_default_buttons() - if self.want_message_bar: - self.insert_message_bar() - self._insert_splitter() if self.want_control_area: self._insert_control_area() if self.want_main_area: self._insert_main_area() + if self.want_message_bar: + # Use a OverlayWidget for status bar positioning. + c = OverlayWidget(self, alignment=Qt.AlignBottom) + c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + c.setWidget(self) + c.setLayout(QVBoxLayout()) + c.layout().setContentsMargins(0, 0, 0, 0) + sb = QStatusBar() + sb.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Maximum) + sb.setSizeGripEnabled(self.resizing_enabled) + c.layout().addWidget(sb) + + self.message_bar = MessagesWidget(self) + self.message_bar.setSizePolicy(QSizePolicy.Preferred, + QSizePolicy.Preferred) + pb = QProgressBar(maximumWidth=120, minimum=0, maximum=100) + pb.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Ignored) + pb.setAttribute(Qt.WA_LayoutUsesWidgetRect) + pb.setAttribute(Qt.WA_MacMiniSize) + pb.hide() + sb.addPermanentWidget(pb) + sb.addPermanentWidget(self.message_bar) + + def statechanged(): + pb.setVisible(bool(self.processingState) or self.isBlocking()) + if self.isBlocking() and not self.processingState: + pb.setRange(0, 0) # indeterminate pb + elif self.processingState: + pb.setRange(0, 100) # determinate pb + + self.processingStateChanged.connect(statechanged) + self.blockingStateChanged.connect(statechanged) + + @self.progressBarValueChanged.connect + def _(val): + pb.setValue(int(val)) + + # Reserve the bottom margins for the status bar + margins = self.layout().contentsMargins() + margins.setBottom(sb.sizeHint().height()) + self.setContentsMargins(margins) + def save_graph(self): """Save the graph with the name given in class attribute `graph_name`. diff --git a/setup.py b/setup.py index cca988fe3a4..bcb1ad4ffab 100755 --- a/setup.py +++ b/setup.py @@ -81,6 +81,10 @@ } +EXTRAS_REQUIRE = { + ':python_version<="3.4"': ["typing"], +} + # Return the git revision as a string def git_version(): """Return the git revision as a string.