diff --git a/.readthedocs.yml b/.readthedocs.yml index 594e3065..8f10f374 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,7 +9,7 @@ build: image: latest python: - version: 3 + version: 3.8 install: - method: pip path: . diff --git a/docs/source/conf.py b/docs/source/conf.py index 10940574..07d84d03 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,8 +39,13 @@ ("py:class", "qtrio._core.EmissionsNursery"), ("py:class", "qtrio._core.Outcomes"), ("py:class", "qtrio._core.Reenter"), + ("py:class", "qtrio._qt.Signal"), # https://github.com/Czaki/sphinx-qt-documentation/issues/10 ("py:class", ""), + ("py:class", ""), + ("py:class", ""), + ("py:class", ""), + ("py:class", ""), # https://github.com/sphinx-doc/sphinx/issues/8136 ("py:class", "typing.AbstractAsyncContextManager"), ] diff --git a/docs/source/core.rst b/docs/source/core.rst index 6c9a330f..8105866f 100644 --- a/docs/source/core.rst +++ b/docs/source/core.rst @@ -28,6 +28,11 @@ their errors on the floor they will be run inside a nursery. .. autofunction:: qtrio.open_emissions_nursery .. autoclass:: qtrio.EmissionsNursery +Helpers +------- + +.. autoclass:: qtrio.Signal + Reentry Events -------------- diff --git a/docs/source/dialogs.rst b/docs/source/dialogs.rst new file mode 100644 index 00000000..47187266 --- /dev/null +++ b/docs/source/dialogs.rst @@ -0,0 +1,38 @@ +Dialogs +======= + +Usage Pattern +------------- + +Creation Functions +------------------ + +.. autofunction:: qtrio.dialogs.create_integer_dialog +.. autofunction:: qtrio.dialogs.create_text_input_dialog +.. autofunction:: qtrio.dialogs.create_file_save_dialog +.. autofunction:: qtrio.dialogs.create_message_box + + +Classes +------- + +.. autoclass:: qtrio.dialogs.IntegerDialog + :members: + +.. autoclass:: qtrio.dialogs.TextInputDialog + :members: + +.. autoclass:: qtrio.dialogs.FileDialog + :members: + +.. autoclass:: qtrio.dialogs.MessageBox + :members: + + +Protocol +-------- + +.. autoclass:: qtrio.dialogs.DialogProtocol + :members: + +.. autofunction:: qtrio.dialogs.check_dialog_protocol diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index 3bab3a76..49b410f4 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -8,3 +8,6 @@ Exceptions .. autoclass:: qtrio.RequestedEventTypeUnavailableError .. autoclass:: qtrio.EventTypeAlreadyRegisteredError .. autoclass:: qtrio.ReturnCodeError +.. autoclass:: qtrio.InternalError +.. autoclass:: qtrio.UserCancelledError +.. autoclass:: qtrio.InvalidInputError diff --git a/docs/source/index.rst b/docs/source/index.rst index b4d4a5d6..e7f9c18b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ tutorial.rst core.rst testing.rst + dialogs.rst exceptions.rst examples/examples.rst development/index.rst diff --git a/newsfragments/2.feature.rst b/newsfragments/2.feature.rst new file mode 100644 index 00000000..b5dc2c85 --- /dev/null +++ b/newsfragments/2.feature.rst @@ -0,0 +1 @@ +Introduce QTrio specific wrappers for some builtin dialogs. diff --git a/qtrio/__init__.py b/qtrio/__init__.py index a4a00f8b..ee76a523 100644 --- a/qtrio/__init__.py +++ b/qtrio/__init__.py @@ -11,6 +11,9 @@ EventTypeAlreadyRegisteredError, ReturnCodeError, RunnerTimedOutError, + UserCancelledError, + InvalidInputError, + InternalError, ) from ._core import ( @@ -28,4 +31,5 @@ Reenter, ) +from ._qt import Signal from ._pytest import host diff --git a/qtrio/_exceptions.py b/qtrio/_exceptions.py index 608251e2..01749b0c 100644 --- a/qtrio/_exceptions.py +++ b/qtrio/_exceptions.py @@ -85,6 +85,13 @@ def __eq__(self, other: object) -> bool: return self.args == other.args +class InternalError(QTrioException): + """Raised when an internal state is inconsistent.""" + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "qtrio" + + class UserCancelledError(QTrioException): """Raised when a user requested cancellation of an operation.""" @@ -97,3 +104,10 @@ class RunnerTimedOutError(QTrioException): # https://github.com/sphinx-doc/sphinx/issues/7493 __module__ = "qtrio" + + +class InvalidInputError(QTrioException): + """Raised when invalid input is provided such as via a dialog.""" + + # https://github.com/sphinx-doc/sphinx/issues/7493 + __module__ = "qtrio" diff --git a/qtrio/_qt.py b/qtrio/_qt.py index b3f80622..b3a1764b 100644 --- a/qtrio/_qt.py +++ b/qtrio/_qt.py @@ -10,10 +10,10 @@ class Signal: - """This is a (nearly) drop-in replacement for QtCore.Signal. The useful difference - is that it does not require inheriting from :class:`QtCore.QObject`. The not-quite - part is that it will be a bit more complicated to change thread affinity of the - relevant :class:`QtCore.QObject`. If you need this, maybe just inherit. + """This is a (nearly) drop-in replacement for :class:`QtCore.Signal`. The useful + difference is that it does not require inheriting from :class:`QtCore.QObject`. The + not-quite part is that it will be a bit more complicated to change thread affinity + of the relevant :class:`QtCore.QObject`. If you need this, maybe just inherit. This signal gets around the normally required inheritance by creating :class:`QtCore.QObject` instances behind the scenes to host the real signals. Just @@ -22,7 +22,7 @@ class Signal: object. """ - attribute_name: str = "" + _attribute_name: typing.ClassVar[str] = "" def __init__(self, *args: object, **kwargs: object) -> None: class _SignalQObject(QtCore.QObject): @@ -39,7 +39,11 @@ def __get__(self, instance: object, owner: object) -> qtrio._util.SignalInstance return o.signal def object(self, instance: object) -> QtCore.QObject: - """Get the :class:`QtCore.QObject` that hosts the real signal. + """Get the :class:`QtCore.QObject` that hosts the real signal. This can be + called such as ``type(instance).signal_name.object(instance)``. Yes this is + non-obvious but you have to do something special to get around the + :ref:`descriptor protocol ` so you can get at this method + instead of just having the underlying :class:`QtCore.SignalInstance`. Arguments: instance: The object on which this descriptor instance is hosted. @@ -47,11 +51,11 @@ def object(self, instance: object) -> QtCore.QObject: Returns: The signal-hosting :class:`QtCore.QObject`. """ - d = getattr(instance, self.attribute_name, None) + d = getattr(instance, self._attribute_name, None) if d is None: d = {} - setattr(instance, self.attribute_name, d) + setattr(instance, self._attribute_name, d) o = d.get(self.object_cls) if o is None: @@ -61,7 +65,7 @@ def object(self, instance: object) -> QtCore.QObject: return o -Signal.attribute_name = qtrio._python.identifier_path(Signal) +Signal._attribute_name = qtrio._python.identifier_path(Signal) @contextlib.contextmanager diff --git a/qtrio/_tests/test_dialogs.py b/qtrio/_tests/test_dialogs.py new file mode 100644 index 00000000..b327fea5 --- /dev/null +++ b/qtrio/_tests/test_dialogs.py @@ -0,0 +1,291 @@ +import os +import sys + +from qtpy import QtCore +from qtpy import QtWidgets +import pytest +import trio + + +import qtrio +import qtrio._core +import qtrio.dialogs +import qtrio._qt + + +def create_message_box_without_arguments(): + return qtrio.dialogs.create_message_box(title="", text="") + + +@pytest.fixture( + name="builder", + params=[ + qtrio.dialogs.create_integer_dialog, + qtrio.dialogs.create_text_input_dialog, + qtrio.dialogs.create_file_save_dialog, + create_message_box_without_arguments, + ], +) +def builder_fixture(request): + yield request.param + + +@qtrio.host +async def test_get_integer_gets_value(request, qtbot): + dialog = qtrio.dialogs.create_integer_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, str(test_value)) + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) + + test_value = 928 + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + integer = await dialog.wait() + + assert integer == test_value + + +@qtrio.host +async def test_get_integer_raises_cancel_when_canceled(request, qtbot): + dialog = qtrio.dialogs.create_integer_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, "abc") + qtbot.mouseClick(dialog.reject_button, QtCore.Qt.LeftButton) + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + +@qtrio.host +async def test_get_integer_raises_for_invalid_input(request, qtbot): + dialog = qtrio.dialogs.create_integer_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.edit_widget, "abc") + qtbot.mouseClick(dialog.accept_button, QtCore.Qt.LeftButton) + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.InvalidInputError): + await dialog.wait() + + +def test_unused_dialog_teardown_ok(builder): + dialog = builder() + dialog.teardown() + + +@qtrio.host(timeout=30) +async def test_file_save(request, qtbot, tmp_path): + path_to_select = trio.Path(tmp_path) / "something.new" + + dialog = qtrio.dialogs.create_file_save_dialog( + default_directory=path_to_select.parent, default_file=path_to_select, + ) + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + + assert dialog.dialog is not None + + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + selected_path = await dialog.wait() + + assert selected_path == path_to_select + + +@qtrio.host(timeout=30) +async def test_file_save_no_defaults(request, qtbot, tmp_path): + path_to_select = trio.Path(tmp_path) / "another.thing" + + dialog = qtrio.dialogs.create_file_save_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + + assert dialog.dialog is not None + + dialog.dialog.setDirectory(os.fspath(path_to_select.parent)) + [text_edit] = dialog.dialog.findChildren(QtWidgets.QLineEdit) + text_edit.setText(path_to_select.name) + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + selected_path = await dialog.wait() + + assert selected_path == path_to_select + + +@qtrio.host(timeout=30) +async def test_file_save_cancelled(request, qtbot, tmp_path): + dialog = qtrio.dialogs.create_file_save_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + # allow cancellation to occur even if the signal was received before the + # cancellation was requested. + await trio.sleep(0) + + assert dialog.dialog is not None + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + +@qtrio.host +async def test_information_message_box(request, qtbot): + text = "Consider yourself informed." + queried_text = None + + dialog = qtrio.dialogs.create_message_box( + title="Information", text=text, icon=QtWidgets.QMessageBox.Information, + ) + + async def user(task_status): + nonlocal queried_text + + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + assert dialog.dialog is not None + + queried_text = dialog.dialog.text() + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + await dialog.wait() + + assert queried_text == text + + +@qtrio.host +async def test_information_message_box_cancel(request, qtbot): + dialog = qtrio.dialogs.create_message_box( + title="", + text="", + icon=QtWidgets.QMessageBox.Information, + buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + ) + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + assert dialog.dialog is not None + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + +@qtrio.host +async def test_text_input_dialog(request, qtbot): + dialog = qtrio.dialogs.create_text_input_dialog() + + entered_text = "etcetera" + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + qtbot.keyClicks(dialog.line_edit, entered_text) + + assert dialog.dialog is not None + + dialog.dialog.accept() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + returned_text = await dialog.wait() + + assert returned_text == entered_text + + +def test_text_input_dialog_with_title(): + title_string = "abc123" + dialog = qtrio.dialogs.create_text_input_dialog(title=title_string) + with qtrio.dialogs._manage(dialog=dialog): + assert dialog.dialog is not None + + assert dialog.dialog.windowTitle() == title_string + + +def test_text_input_dialog_with_label(): + label_string = "lmno789" + dialog = qtrio.dialogs.create_text_input_dialog(label=label_string) + with qtrio.dialogs._manage(dialog=dialog): + assert dialog.dialog is not None + + [label] = dialog.dialog.findChildren(QtWidgets.QLabel) + assert label.text() == label_string + + +@qtrio.host +async def test_text_input_dialog_cancel(request, qtbot): + dialog = qtrio.dialogs.create_text_input_dialog() + + async def user(task_status): + async with qtrio._core.wait_signal_context(dialog.shown): + task_status.started() + + assert dialog.dialog is not None + + dialog.dialog.reject() + + async with trio.open_nursery() as nursery: + await nursery.start(user) + with qtrio._qt.connection(signal=dialog.shown, slot=qtbot.addWidget): + with pytest.raises(qtrio.UserCancelledError): + await dialog.wait() + + +def test_dialog_button_box_buttons_by_role_no_buttons(qtbot): + dialog = QtWidgets.QDialog() + assert qtrio.dialogs._dialog_button_box_buttons_by_role(dialog=dialog) == {} diff --git a/qtrio/_tests/test_qt.py b/qtrio/_tests/test_qt.py index c02398d8..dd5f1121 100644 --- a/qtrio/_tests/test_qt.py +++ b/qtrio/_tests/test_qt.py @@ -8,7 +8,7 @@ def test_signal_emits(qtbot): """qtrio._core.Signal emits.""" class NotQObject: - signal = qtrio._qt.Signal() + signal = qtrio.Signal() instance = NotQObject() @@ -20,7 +20,7 @@ def test_signal_emits_value(qtbot): """qtrio._core.Signal emits a value.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) result = None @@ -41,16 +41,16 @@ def test_accessing_signal_on_class_results_in_our_signal(): """qtrio._core.Signal instance accessible via class attribute.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) - assert isinstance(NotQObject.signal, qtrio._qt.Signal) + assert isinstance(NotQObject.signal, qtrio.Signal) def test_our_signal_object_method_returns_qobject(): """qtrio._core.Signal instance provides access to signal-hosting QObject.""" class NotQObject: - signal = qtrio._qt.Signal(int) + signal = qtrio.Signal(int) instance = NotQObject() diff --git a/qtrio/dialogs.py b/qtrio/dialogs.py new file mode 100644 index 00000000..047239f0 --- /dev/null +++ b/qtrio/dialogs.py @@ -0,0 +1,513 @@ +import contextlib +import os +import sys +import typing + +import attr +from qtpy import QtWidgets +import trio + +import qtrio +import qtrio._qt + + +if sys.version_info >= (3, 8): + from typing import Protocol +else: + from typing_extensions import Protocol + + +class DialogProtocol(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.""" + finished: qtrio.Signal + """The signal to be emitted when the dialog is finished.""" + + def setup(self) -> None: + """Setup and show the dialog. Emit :attr:`qtrio.dialogs.DialogProtocol.shown` + when done. + """ + + def teardown(self) -> None: + """Hide and teardown the dialog.""" + + 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. + """ + + +DialogProtocolT = typing.TypeVar("DialogProtocolT", bound=DialogProtocol) + + +def check_dialog_protocol( + cls: typing.Type[DialogProtocolT], +) -> typing.Type[DialogProtocolT]: + """Decorate a class with this to verify it implements the + :class:`qtrio.dialogs.DialogProtocol` when a type hint checker such as mypy is run + against the code. At runtime the passed class is cleanly returned. + + Arguments: + cls: The class to verify. + """ + return cls + + +@contextlib.contextmanager +def _manage(dialog: DialogProtocol) -> 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 = dialog.findChildren(QtWidgets.QDialogButtonBox) + + if len(hits) == 0: + return {} + + [button_box] = hits + return {button_box.buttonRole(button): button for button in button_box.buttons()} + + +@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.QPushButton] = None + """The entry confirmation button.""" + reject_button: typing.Optional[QtWidgets.QPushButton] = 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.DialogProtocol.finished`.""" + + def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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) + + def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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 + + 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 + + +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) + + +@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.QPushButton] = None + """The entry confirmation button.""" + reject_button: typing.Optional[QtWidgets.QPushButton] = 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.DialogProtocol.finished`.""" + + def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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) + + def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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 + + async def wait(self) -> 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." + ) + + 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 + + +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) + + +@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.QPushButton] = None + """The confirmation button.""" + reject_button: typing.Optional[QtWidgets.QPushButton] = None + """The cancellation button.""" + + 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.DialogProtocol.finished`.""" + + def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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 + + self.dialog = 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.shown.emit(self.dialog) + + def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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 + + async def wait(self) -> 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." + ) + + 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 + + +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 an open or 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, + ) + + +@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: QtWidgets.QMessageBox.StandardButtons + """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.QPushButton] = 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.DialogProtocol.finished`.""" + + def setup(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.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) + + def teardown(self) -> None: + """See :meth:`qtrio.dialogs.DialogProtocol.teardown`.""" + + if self.dialog is not None: + self.dialog.close() + self.dialog = None + self.accept_button = None + + async def wait(self) -> 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." + ) + + await finished_event.wait() + + result = self.dialog.result() + + if result == QtWidgets.QDialog.Rejected: + raise qtrio.UserCancelledError() + + +def create_message_box( + title: str, + text: str, + icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Information, + buttons: QtWidgets.QMessageBox.StandardButtons = 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) diff --git a/setup.py b/setup.py index 581c17a5..edc2a5aa 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,8 @@ "pytest", "qtpy", "trio>=0.16", + # python_version < '3.8' for `Protocol` + "typing-extensions; python_version < '3.8'", ], extras_require={ "checks": ["black", "flake8", "mypy", towncrier],