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.