import contextlib
import os
import sys
import typing
import async_generator
import attr
from qts import QtWidgets
import trio
import trio_typing
import qtrio
import qtrio._qt
import qtrio._util
if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol
[docs]class BasicDialogProtocol(Protocol):
"""The minimal common interface used for working with QTrio dialogs. To check that
a class implements this protocol see
:func:`qtrio.dialogs.check_basic_dialog_protocol`.
"""
finished: qtrio.Signal
"""The signal to be emitted when the dialog is finished."""
[docs] def setup(self) -> None:
"""Setup and show the dialog. Emit :attr:`qtrio.dialogs.DialogProtocol.shown`
when done.
"""
[docs] def teardown(self) -> None:
"""Hide and teardown the dialog."""
check_basic_dialog_protocol = qtrio._util.ProtocolChecker[BasicDialogProtocol]()
"""Assert proper implementation of :class:`qtrio.dialogs.BasicDialogProtocol` when type
hint checking.
Arguments:
cls: The class to verify.
Returns:
The same class, unmodified, that was passed in.
"""
[docs]class DialogProtocol(BasicDialogProtocol, Protocol):
"""The common interface used for working with QTrio dialogs. To check that a class
implements this protocol see :func:`qtrio.dialogs.check_dialog_protocol`.
"""
shown: qtrio.Signal
"""The signal to be emitted when the dialog is shown."""
[docs] async def wait(self) -> object:
"""Show the dialog, wait for the user interaction, and return the result.
Raises:
qtrio.InvalidInputError: If the input can't be parsed as an integer.
qtrio.UserCancelledError: If the user cancels the dialog.
"""
check_dialog_protocol = qtrio._util.ProtocolChecker[DialogProtocol]()
"""Assert proper implementation of :class:`qtrio.dialogs.DialogProtocol` when type hint
checking.
Arguments:
cls: The class to verify.
Returns:
The same class, unmodified, that was passed in.
"""
@contextlib.contextmanager
def _manage(dialog: BasicDialogProtocol) -> typing.Generator[trio.Event, None, None]:
"""Manage the setup and teardown of a dialog including yielding a
:class:`trio.Event` that is set when the dialog is finished.
Arguments:
dialog: The dialog to be managed.
"""
finished_event = trio.Event()
def slot(*args: object, **kwargs: object) -> None:
"""Accept and ignore all arguments, then set the event."""
finished_event.set()
with qtrio._qt.connection(signal=dialog.finished, slot=slot):
try:
dialog.setup()
yield finished_event
finally:
dialog.teardown()
def _dialog_button_box_buttons_by_role(
dialog: QtWidgets.QDialog,
) -> typing.Mapping[QtWidgets.QDialogButtonBox.ButtonRole, QtWidgets.QAbstractButton]:
"""Create a mapping from button roles to their corresponding buttons."""
hits = list(dialog.findChildren(QtWidgets.QDialogButtonBox))
if len(hits) == 0:
return {}
[button_box] = hits
return {button_box.buttonRole(button): button for button in button_box.buttons()}
[docs]@check_dialog_protocol
@attr.s(auto_attribs=True)
class IntegerDialog:
"""Manage a dialog for inputting an integer from the user. Generally instances
should be built via :func:`qtrio.dialogs.create_integer_dialog`."""
parent: typing.Optional[QtWidgets.QWidget] = None
"""The parent widget for the dialog."""
dialog: typing.Optional[QtWidgets.QInputDialog] = None
"""The actual dialog widget instance."""
edit_widget: typing.Optional[QtWidgets.QLineEdit] = None
"""The line edit that the user will enter the input into."""
accept_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The entry confirmation button."""
reject_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The input cancellation button."""
result: typing.Optional[int] = None
"""The result of parsing the user input."""
shown = qtrio.Signal(QtWidgets.QInputDialog)
"""See :attr:`qtrio.dialogs.DialogProtocol.shown`."""
finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode
"""See :attr:`qtrio.dialogs.BasicDialogProtocol.finished`."""
[docs] def setup(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.setup`."""
self.result = None
self.dialog = QtWidgets.QInputDialog(self.parent)
# TODO: adjust so we can use a context manager?
self.dialog.finished.connect(self.finished)
self.dialog.show()
buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog)
self.accept_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole)
self.reject_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole)
[self.edit_widget] = self.dialog.findChildren(QtWidgets.QLineEdit)
self.shown.emit(self.dialog)
[docs] def teardown(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.teardown`."""
if self.dialog is not None:
self.dialog.close()
self.dialog.finished.disconnect(self.finished)
self.dialog = None
self.accept_button = None
self.reject_button = None
self.edit_widget = None
[docs] async def wait(self) -> int:
"""See :meth:`qtrio.dialogs.DialogProtocol.wait`."""
with _manage(dialog=self) as finished_event:
if self.dialog is None: # pragma: no cover
raise qtrio.InternalError(
"Dialog not assigned while it is being managed."
)
await finished_event.wait()
if self.dialog.result() != QtWidgets.QDialog.Accepted:
raise qtrio.UserCancelledError()
try:
self.result = int(self.dialog.textValue())
except ValueError:
raise qtrio.InvalidInputError()
return self.result
[docs]def create_integer_dialog(
parent: typing.Optional[QtWidgets.QWidget] = None,
) -> IntegerDialog:
"""Create an integer input dialog.
Arguments:
parent: See :attr:`qtrio.dialogs.IntegerDialog.parent`.
Returns:
The dialog manager.
"""
return IntegerDialog(parent=parent)
[docs]@check_dialog_protocol
@attr.s(auto_attribs=True)
class TextInputDialog:
"""Manage a dialog for inputting an integer from the user. Generally instances
should be built via :func:`qtrio.dialogs.create_text_input_dialog`."""
title: typing.Optional[str] = None
"""The title of the dialog."""
label: typing.Optional[str] = None
"""The label for the input widget."""
parent: typing.Optional[QtWidgets.QWidget] = None
"""The parent widget for the dialog."""
dialog: typing.Optional[QtWidgets.QInputDialog] = None
"""The actual dialog widget instance."""
accept_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The entry confirmation button."""
reject_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The input cancellation button."""
line_edit: typing.Optional[QtWidgets.QLineEdit] = None
"""The line edit that the user will enter the input into."""
result: typing.Optional[str] = None
"""The result of parsing the user input."""
shown = qtrio.Signal(QtWidgets.QInputDialog)
"""See :attr:`qtrio.dialogs.DialogProtocol.shown`."""
finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode
"""See :attr:`qtrio.dialogs.BasicDialogProtocol.finished`."""
finished_event: trio.Event = attr.ib(factory=trio.Event)
[docs] def setup(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.setup`."""
self.result = None
self.dialog = QtWidgets.QInputDialog(parent=self.parent)
if self.label is not None:
self.dialog.setLabelText(self.label)
if self.title is not None:
self.dialog.setWindowTitle(self.title)
# TODO: adjust so we can use a context manager?
self.dialog.finished.connect(self.finished)
self.dialog.show()
buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog)
self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole]
self.reject_button = buttons[QtWidgets.QDialogButtonBox.RejectRole]
[self.line_edit] = self.dialog.findChildren(QtWidgets.QLineEdit)
self.shown.emit(self.dialog)
[docs] def teardown(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.teardown`."""
if self.dialog is not None:
self.dialog.close()
self.dialog.finished.disconnect(self.finished)
self.dialog = None
self.accept_button = None
self.reject_button = None
[docs] async def wait(self, shown_event: trio.Event = trio.Event()) -> str:
"""See :meth:`qtrio.dialogs.DialogProtocol.wait`."""
with _manage(dialog=self) as finished_event:
if self.dialog is None: # pragma: no cover
raise qtrio.InternalError(
"Dialog not assigned while it is being managed."
)
shown_event.set()
await finished_event.wait()
dialog_result = self.dialog.result()
if dialog_result == QtWidgets.QDialog.Rejected:
raise qtrio.UserCancelledError()
# TODO: `: str` is a workaround for
# https://github.com/spyder-ide/qtpy/pull/217
text_result: str = self.dialog.textValue()
self.result = text_result
return text_result
[docs]def create_text_input_dialog(
title: typing.Optional[str] = None,
label: typing.Optional[str] = None,
parent: typing.Optional[QtWidgets.QWidget] = None,
) -> TextInputDialog:
"""Create a text input dialog.
Arguments:
title: The text to use for the input dialog title bar.
label: The text to use for the input text box label.
parent: See :attr:`qtrio.dialogs.IntegerDialog.parent`.
Returns:
The dialog manager.
"""
return TextInputDialog(title=title, label=label, parent=parent)
[docs]@check_dialog_protocol
@attr.s(auto_attribs=True)
class FileDialog:
"""Manage a dialog for allowing the user to select a file or directory. Generally
instances should be built via :func:`qtrio.dialogs.create_file_save_dialog`."""
file_mode: QtWidgets.QFileDialog.FileMode
"""Controls whether the dialog is for picking an existing vs. new file or directory,
etc.
"""
accept_mode: QtWidgets.QFileDialog.AcceptMode
"""Specify an open vs. a save dialog."""
default_directory: typing.Optional[trio.Path] = None
"""The directory to be initially presented in the dialog."""
default_file: typing.Optional[trio.Path] = None
"""The file to be initially selected in the dialog."""
options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option()
"""Miscellaneous options. See the Qt documentation."""
parent: typing.Optional[QtWidgets.QWidget] = None
"""The parent widget for the dialog."""
dialog: typing.Optional[QtWidgets.QFileDialog] = None
"""The actual dialog widget instance."""
accept_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The confirmation button."""
reject_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The cancellation button."""
file_name_line_edit: typing.Optional[QtWidgets.QLineEdit] = None
"""The file name line edit widget."""
result: typing.Optional[trio.Path] = None
"""The path selected by the user."""
shown = qtrio.Signal(QtWidgets.QFileDialog)
"""See :attr:`qtrio.dialogs.DialogProtocol.shown`."""
finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode
"""See :attr:`qtrio.dialogs.BasicDialogProtocol.finished`."""
[docs] async def set_path(self, path: trio.Path) -> None:
"""Set the directory and enter the file name in the text box. Note that this
does not select the file in the file list.
Arguments:
path: The full path to the file to be set.
"""
if self.dialog is None:
raise qtrio.DialogNotActiveError(
"File dialog not available for interaction."
)
self.dialog.setDirectory(os.fspath(path.parent))
if self.file_name_line_edit is None: # pragma: no cover
raise qtrio.InternalError("File name line edit unexpectedly not known.")
self.file_name_line_edit.setText(path.name)
[docs] def setup(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.setup`."""
self.result = None
extras = {}
if self.default_directory is not None:
extras["directory"] = os.fspath(self.default_directory)
options = self.options
if sys.platform == "darwin":
# https://github.com/altendky/qtrio/issues/28
options |= QtWidgets.QFileDialog.DontUseNativeDialog
# TODO: huh, so ``options`` is entirely undocumented... and hints an ``Any``
# return.
self.dialog = typing.cast(
QtWidgets.QFileDialog,
QtWidgets.QFileDialog(parent=self.parent, options=options, **extras),
)
if self.default_file is not None:
self.dialog.selectFile(os.fspath(self.default_file))
self.dialog.setFileMode(self.file_mode)
self.dialog.setAcceptMode(self.accept_mode)
# TODO: adjust so we can use a context manager?
self.dialog.finished.connect(self.finished)
self.dialog.show()
buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog)
self.accept_button = buttons.get(QtWidgets.QDialogButtonBox.AcceptRole)
self.reject_button = buttons.get(QtWidgets.QDialogButtonBox.RejectRole)
[self.file_name_line_edit] = self.dialog.findChildren(QtWidgets.QLineEdit)
self.shown.emit(self.dialog)
[docs] def teardown(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.teardown`."""
if self.dialog is not None:
self.dialog.close()
self.dialog.finished.disconnect(self.finished)
self.dialog = None
self.accept_button = None
self.reject_button = None
self.file_name_line_edit = None
[docs] async def wait(self, shown_event: trio.Event = trio.Event()) -> trio.Path:
"""See :meth:`qtrio.dialogs.DialogProtocol.wait`."""
with _manage(dialog=self) as finished_event:
if self.dialog is None: # pragma: no cover
raise qtrio.InternalError(
"Dialog not assigned while it is being managed."
)
shown_event.set()
await finished_event.wait()
if self.dialog.result() != QtWidgets.QDialog.Accepted:
raise qtrio.UserCancelledError()
[path_string] = self.dialog.selectedFiles()
self.result = trio.Path(path_string)
return self.result
[docs]def create_file_save_dialog(
parent: typing.Optional[QtWidgets.QWidget] = None,
default_directory: typing.Optional[trio.Path] = None,
default_file: typing.Optional[trio.Path] = None,
options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(),
) -> FileDialog:
"""Create a file save dialog.
Arguments:
parent: See :attr:`qtrio.dialogs.FileDialog.parent`.
default_directory: See :attr:`qtrio.dialogs.FileDialog.default_directory`.
default_file: See :attr:`qtrio.dialogs.FileDialog.default_file`.
options: See :attr:`qtrio.dialogs.FileDialog.options`.
"""
return FileDialog(
parent=parent,
default_directory=default_directory,
default_file=default_file,
options=options,
file_mode=QtWidgets.QFileDialog.AnyFile,
accept_mode=QtWidgets.QFileDialog.AcceptSave,
)
[docs]def create_file_open_dialog(
parent: typing.Optional[QtWidgets.QWidget] = None,
default_directory: typing.Optional[trio.Path] = None,
default_file: typing.Optional[trio.Path] = None,
options: QtWidgets.QFileDialog.Option = QtWidgets.QFileDialog.Option(),
) -> FileDialog:
"""Create a file open dialog.
Arguments:
parent: See :attr:`qtrio.dialogs.FileDialog.parent`.
default_directory: See :attr:`qtrio.dialogs.FileDialog.default_directory`.
default_file: See :attr:`qtrio.dialogs.FileDialog.default_file`.
options: See :attr:`qtrio.dialogs.FileDialog.options`.
"""
return FileDialog(
parent=parent,
default_directory=default_directory,
default_file=default_file,
options=options,
file_mode=QtWidgets.QFileDialog.AnyFile,
accept_mode=QtWidgets.QFileDialog.AcceptOpen,
)
message_box_standard_button_union = typing.Union[
QtWidgets.QMessageBox.StandardButton,
QtWidgets.QMessageBox.StandardButtons,
]
[docs]@check_dialog_protocol
@attr.s(auto_attribs=True)
class MessageBox:
"""Manage a message box for notifying the user. Generally instances should be built
via :func:`qtrio.dialogs.create_message_box`.
"""
title: str
"""The message box title."""
text: str
"""The message text shown inside the dialog."""
icon: QtWidgets.QMessageBox.Icon
"""The icon shown inside the dialog."""
buttons: message_box_standard_button_union
"""The buttons to be shown in the dialog."""
parent: typing.Optional[QtWidgets.QWidget] = None
"""The parent widget for the dialog."""
dialog: typing.Optional[QtWidgets.QMessageBox] = None
"""The actual dialog widget instance."""
accept_button: typing.Optional[QtWidgets.QAbstractButton] = None
"""The button to accept the dialog."""
result: typing.Optional[trio.Path] = None
"""Not generally relevant for a message box."""
shown = qtrio.Signal(QtWidgets.QMessageBox)
"""See :attr:`qtrio.dialogs.DialogProtocol.shown`."""
finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode
"""See :attr:`qtrio.dialogs.BasicDialogProtocol.finished`."""
[docs] def setup(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.setup`."""
self.result = None
self.dialog = QtWidgets.QMessageBox(
self.icon,
self.title,
self.text,
self.buttons,
self.parent,
)
# TODO: adjust so we can use a context manager?
self.dialog.finished.connect(self.finished)
self.dialog.show()
buttons = _dialog_button_box_buttons_by_role(dialog=self.dialog)
self.accept_button = buttons[QtWidgets.QDialogButtonBox.AcceptRole]
self.shown.emit(self.dialog)
[docs] def teardown(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.teardown`."""
if self.dialog is not None:
self.dialog.close()
self.dialog = None
self.accept_button = None
[docs] async def wait(self, shown_event: trio.Event = trio.Event()) -> None:
"""See :meth:`qtrio.dialogs.DialogProtocol.wait`."""
with _manage(dialog=self) as finished_event:
if self.dialog is None: # pragma: no cover
raise qtrio.InternalError(
"Dialog not assigned while it is being managed."
)
shown_event.set()
await finished_event.wait()
result = self.dialog.result()
if result == QtWidgets.QDialog.Rejected:
raise qtrio.UserCancelledError()
[docs]def create_message_box(
title: str = "",
text: str = "",
icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information,
buttons: message_box_standard_button_union = QtWidgets.QMessageBox.Ok,
parent: typing.Optional[QtWidgets.QWidget] = None,
) -> MessageBox:
"""Create a message box.
Arguments:
title: See :attr:`qtrio.dialogs.MessageBox.title`.
text: See :attr:`qtrio.dialogs.MessageBox.text`.
icon: See :attr:`qtrio.dialogs.MessageBox.icon`.
buttons: See :attr:`qtrio.dialogs.MessageBox.buttons`.
parent: See :attr:`qtrio.dialogs.MessageBox.parent`.
"""
return MessageBox(icon=icon, title=title, text=text, buttons=buttons, parent=parent)
[docs]@check_basic_dialog_protocol
@attr.s(auto_attribs=True)
class ProgressDialog:
"""Manage a progress dialog for updating the user. Generally instances should be
built via :func:`qtrio.dialogs.create_progress_dialog`.
"""
title: str
"""The message box title."""
text: str
"""The message text shown as the progress bar label."""
cancel_button_text: typing.Optional[str]
"""The cancel button text."""
minimum: int
"""The progress value corresponding to no progress."""
maximum: int
"""The progress value corresponding to completion."""
parent: typing.Optional[QtWidgets.QWidget] = None
"""The parent widget for the dialog."""
dialog: typing.Optional[QtWidgets.QProgressDialog] = None
"""The actual dialog widget instance."""
cancel_button: typing.Optional[QtWidgets.QPushButton] = None
"""The cancellation button."""
shown = qtrio.Signal(QtWidgets.QMessageBox)
"""See :attr:`qtrio.dialogs.DialogProtocol.shown`."""
finished = qtrio.Signal(int) # QtWidgets.QDialog.DialogCode
"""See :attr:`qtrio.dialogs.BasicDialogProtocol.finished`."""
[docs] def setup(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.setup`."""
self.dialog = QtWidgets.QProgressDialog()
self.dialog.setWindowTitle(self.title)
self.dialog.setLabelText(self.text)
self.dialog.setMinimum(self.minimum)
self.dialog.setMaximum(self.maximum)
if self.parent is not None:
self.dialog.setParent(self.parent)
# TODO: adjust so we can use a context manager?
self.dialog.finished.connect(self.finished)
if self.cancel_button_text is not None:
self.dialog.setCancelButtonText(self.cancel_button_text)
self.dialog.show()
[self.cancel_button] = self.dialog.findChildren(QtWidgets.QPushButton)
self.shown.emit(self.dialog)
[docs] def teardown(self) -> None:
"""See :meth:`qtrio.dialogs.BasicDialogProtocol.teardown`."""
if self.dialog is not None:
self.dialog.close()
self.dialog.finished.disconnect(self.finished)
self.dialog = None
self.cancel_button = None
[docs] @async_generator.asynccontextmanager
async def manage(self) -> typing.AsyncIterator[None]:
"""A context manager to setup the progress dialog, cancel the managed context
and teardown the dialog when done.
"""
with _manage(dialog=self):
if self.dialog is None: # pragma: no cover
raise qtrio.InternalError(
"Dialog not assigned while it is being managed."
)
with trio.CancelScope() as cancel_scope:
with qtrio._qt.connection(
signal=self.dialog.canceled, slot=cancel_scope.cancel
):
yield
if self.dialog.wasCanceled():
raise qtrio.UserCancelledError()
[docs]def create_progress_dialog(
title: str = "",
text: str = "",
cancel_button_text: typing.Optional[str] = None,
minimum: int = 0,
maximum: int = 0,
parent: typing.Optional[QtWidgets.QWidget] = None,
) -> ProgressDialog:
"""Create a progress dialog.
Arguments:
title: See :attr:`qtrio.dialogs.ProgressDialog.title`.
text: See :attr:`qtrio.dialogs.ProgressDialog.text`.
cancel_button_text: See :attr:`qtrio.dialogs.ProgressDialog.cancel_button_text`.
minimum: See :attr:`qtrio.dialogs.ProgressDialog.minimum`.
maximum: See :attr:`qtrio.dialogs.ProgressDialog.maximum`.
parent: See :attr:`qtrio.dialogs.ProgressDialog.parent`.
"""
return ProgressDialog(
title=title,
text=text,
cancel_button_text=cancel_button_text,
minimum=minimum,
maximum=maximum,
parent=parent,
)