# -*- coding:utf-8 -*-
"""
This module defines several reusable dialog types.
"""
import traceback
from PySide2 import QtCore, QtWidgets
# #############################################################################
# # DIALOGS
# #############################################################################
[docs]class FlexibleDialog(QtWidgets.QDialog):
"""
Dialog class that allows for OK, Yes/No, and timeout interactions.
To extend this dialog class, override ``setup_ui_body``, ``on_accept`` and
``on_reject``, store the instance and call it with ``show`` or ``exec_``.
Note that ``setup_ui_body`` is being called IN the constructor, so any
variables that it may need when extending the class need to be set before
``super().__init__`` is called.
As it can be seen here,
https://stackoverflow.com/questions/56449605/pyside2-qdialog-possible-bug
implementing a Dialog in PySide2 is a little tricky. These are some things
to consider:
* Do not implement ``accept, reject`` directly. Rather, connect the buttons
to ``accept, reject``, and then connect the ``accepted, rejected``
signals to custom methods (in this case ``on_accept, on_reject``).
* When calling the Dialog from the main window, the dialog must be
persistently stored as a field of the main window ``i.e. self.d = ...``.
Otherwise it will not show up. Then it can be called in modal or modeless
way, as follows: ``XXX.connect(self.d.show), ...(self.d.exec_)``.
"""
TIMEOUT_LBL_TXT = "Closing in {} seconds..."
def __init__(self, accept_button_name=None, reject_button_name=None,
timeout_ms=None, parent=None):
"""
:param str accept_button_name: Text to be shown in the accept button.
:param str reject_button_name: Text to be shown in the reject button.
:param int timeout_ms: If given, time that the dialog takes to close
automatically, in milliseconds.
"""
super().__init__(parent)
# The body goes into ui_widget
self.ui_widget = QtWidgets.QWidget()
self.setup_ui_body(self.ui_widget)
#
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(self.ui_widget)
# Then comes the (optional) buttons section
with_a = accept_button_name is not None
with_r = reject_button_name is not None
if with_a or with_r:
button_layout = QtWidgets.QHBoxLayout()
main_layout.addLayout(button_layout)
#
if with_a:
self.accept_b = QtWidgets.QPushButton(accept_button_name)
button_layout.addWidget(self.accept_b)
self.accept_b.clicked.connect(self.accept)
self.accepted.connect(self.on_accept)
# remove autodefault, if you want default set it explicitly
self.accept_b.setAutoDefault(False)
#
if with_r:
self.reject_b = QtWidgets.QPushButton(reject_button_name)
button_layout.addWidget(self.reject_b)
self.reject_b.clicked.connect(self.reject)
self.rejected.connect(self.on_reject)
# remove autodefault, if you want default set it explicitly
self.reject_b.setAutoDefault(False)
# Finally the (optional) timeout message
if timeout_ms is not None:
assert isinstance(timeout_ms, int), \
"Timeout miliseconds must be int or None!"
self.timeout_lbl = QtWidgets.QLabel(
self.TIMEOUT_LBL_TXT.format(timeout_ms / 1000))
main_layout.addWidget(self.timeout_lbl)
self.timeout_ms = timeout_ms
def _set_timer(self):
"""
"""
if self.timeout_ms is not None:
QtCore.QTimer.singleShot(self.timeout_ms, self.reject)
[docs] def exec_(self, *args, **kwargs):
"""
Start the dialog in 'exclusive' way, blocking the rest of the app.
"""
self._set_timer()
return super().exec_(*args, **kwargs)
[docs] def show(self, *args, **kwargs):
"""
Start the dialog in parallel to the rest of the app.
"""
self._set_timer()
return super().show(*args, **kwargs)
# OVERRIDE THESE
[docs] def setup_ui_body(self, widget):
"""
Populate the widget with your desired contents. The widget will
be above the buttons.
"""
pass
[docs] def on_accept(self):
"""
This method will be called if the user presses the (optional)
accept button.
"""
pass
[docs] def on_reject(self):
"""
This method will be called if the user presses the (optional)
reject button.
"""
pass
[docs]class InfoDialog(FlexibleDialog):
"""
A type of dialog that shows a header and body strings.
"""
# If true, the user can select the text with the mouse
INTERACT_HEADER = False
INTERACT_BODY = True
def __init__(self, header, message, accept_button_name=None,
reject_button_name=None, timeout_ms=None, parent=None,
print_msg=True,
header_style="font-weight: bold; color: black"):
"""
:param str header: This will be the dialog title
:param str message: This will go below the title, separated by a line
:param header_style: A CSS-like style to format the header.
Check ``FlexibleDialog`` docstrings for more details.
"""
self._h_txt = header
self._msg_txt = message
self.print_msg = print_msg
self._h_style = header_style
super().__init__(accept_button_name, reject_button_name, timeout_ms,
parent)
[docs] def setup_ui_body(self, widget):
"""
"""
lyt = QtWidgets.QVBoxLayout(widget)
#
self.header_lbl = QtWidgets.QLabel(self._h_txt)
self.body_lbl = QtWidgets.QLabel(self._msg_txt)
self.line = QtWidgets.QFrame()
self.line.setFrameShape(QtWidgets.QFrame.HLine)
self.line.setFrameShadow(QtWidgets.QFrame.Sunken)
#
lyt.addWidget(self.header_lbl)
lyt.addWidget(self.line)
lyt.addWidget(self.body_lbl)
if self.INTERACT_HEADER:
self.header_lbl.setTextInteractionFlags(
QtCore.Qt.TextSelectableByMouse)
if self.INTERACT_BODY:
self.body_lbl.setTextInteractionFlags(
QtCore.Qt.TextSelectableByMouse)
#
self.header_lbl.setStyleSheet(self._h_style)
def _print_if(self):
"""
"""
if self.print_msg:
print(self._h_txt)
print(self._msg_txt)
[docs] def exec_(self, *args, **kwargs):
"""
"""
outcome = super().exec_(*args, **kwargs)
self._print_if()
return outcome
[docs] def show(self, *args, **kwargs):
"""
"""
outcome = super().show(*args, **kwargs)
self._print_if()
return outcome
[docs]class ExceptionDialog(InfoDialog):
"""
This class is intended to be used at the main loop level,
to catch any exceptions that the app may have and show them
in a Dialog. To do that, it suffices to put the following
line anywhere before ``app.exec_()``::
sys.excepthook = ExceptionDialog.excepthook
Source: https://stackoverflow.com/a/55819545/4511978
"""
DEACTIVATE = False
ERROR_TXT = """
ERROR!
If you think this is an app error consider reporting the following
to the developers (see Help->About):"""
def __init__(self, error_msg, timeout_ms=None, parent=None):
"""
Check ``InfoDialog`` docstring for details.
"""
super().__init__(self.ERROR_TXT, error_msg, "OK",
"Don't show errors again",
header_style="font-weight: bold; color: red")
[docs] def on_reject(self):
"""
If the user presses on don't show errors again, the whole class
gets deactivated, so further created instances won't pop up.
"""
print("deactivated!")
self.__class__.DEACTIVATE = True
[docs] @classmethod
def excepthook(cls, exc_type, exc_value, exc_tb):
"""
Set this method as ``sys.excepthook = <THIS_CLASS>.excepthook``
somewhere before ``app.exec_()`` to wrap all Python exceptions with
this dialog.
"""
msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
print(cls.ERROR_TXT)
print(msg)
#
if not cls.DEACTIVATE:
cls(msg).exec_()