Skip to content

Commit ee972f3

Browse files
feat: add request_values convenience, shows modal dialog to request values (#416)
* feat: add show_values_dialog * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix test * add docstring * prevent dialog show in tests * fix test Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2379219 commit ee972f3

File tree

11 files changed

+173
-9
lines changed

11 files changed

+173
-9
lines changed

examples/values_dialog.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from magicgui.widgets import request_values
2+
3+
vals = request_values(
4+
age=int,
5+
name=dict(annotation=str, label="Enter your name:"),
6+
title="Hi, who are you?",
7+
)
8+
print(repr(vals))

magicgui/backends/_qtpy/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Container,
66
DateEdit,
77
DateTimeEdit,
8+
Dialog,
89
EmptyWidget,
910
FloatSlider,
1011
FloatSpinBox,
@@ -34,6 +35,7 @@
3435
"Container",
3536
"DateEdit",
3637
"DateTimeEdit",
38+
"Dialog",
3739
"EmptyWidget",
3840
"FloatSlider",
3941
"FloatSpinBox",

magicgui/backends/_qtpy/widgets.py

+46
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,52 @@ def _mgui_get_value(self):
10251025
return self._qwidget.time().toPyTime()
10261026

10271027

1028+
class Dialog(QBaseWidget, _protocols.ContainerProtocol):
1029+
def __init__(self, layout="vertical", **k):
1030+
QBaseWidget.__init__(self, QtW.QDialog)
1031+
if layout == "horizontal":
1032+
self._layout: QtW.QBoxLayout = QtW.QHBoxLayout()
1033+
else:
1034+
self._layout = QtW.QVBoxLayout()
1035+
self._qwidget.setLayout(self._layout)
1036+
self._layout.setSizeConstraint(QtW.QLayout.SizeConstraint.SetMinAndMaxSize)
1037+
1038+
button_box = QtW.QDialogButtonBox(
1039+
QtW.QDialogButtonBox.StandardButton.Ok
1040+
| QtW.QDialogButtonBox.StandardButton.Cancel,
1041+
Qt.Orientation.Horizontal,
1042+
self._qwidget,
1043+
)
1044+
button_box.accepted.connect(self._qwidget.accept)
1045+
button_box.rejected.connect(self._qwidget.reject)
1046+
self._layout.addWidget(button_box)
1047+
1048+
def _mgui_insert_widget(self, position: int, widget: Widget):
1049+
self._layout.insertWidget(position, widget.native)
1050+
1051+
def _mgui_remove_widget(self, widget: Widget):
1052+
self._layout.removeWidget(widget.native)
1053+
widget.native.setParent(None)
1054+
1055+
def _mgui_get_margins(self) -> tuple[int, int, int, int]:
1056+
m = self._layout.contentsMargins()
1057+
return m.left(), m.top(), m.right(), m.bottom()
1058+
1059+
def _mgui_set_margins(self, margins: tuple[int, int, int, int]) -> None:
1060+
self._layout.setContentsMargins(*margins)
1061+
1062+
def _mgui_set_orientation(self, value) -> None:
1063+
"""Set orientation, value will be 'horizontal' or 'vertical'."""
1064+
raise NotImplementedError("Setting orientation not supported for dialogs.")
1065+
1066+
def _mgui_get_orientation(self) -> str:
1067+
"""Set orientation, return either 'horizontal' or 'vertical'."""
1068+
return "horizontal" if isinstance(self, QtW.QHBoxLayout) else "vertical"
1069+
1070+
def _mgui_exec(self) -> Any:
1071+
return self._qwidget.exec_()
1072+
1073+
10281074
QFILE_DIALOG_MODES = {
10291075
FileDialogMode.EXISTING_FILE: QtW.QFileDialog.getOpenFileName,
10301076
FileDialogMode.EXISTING_FILES: QtW.QFileDialog.getOpenFileNames,

magicgui/widgets/__init__.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Container,
1919
DateEdit,
2020
DateTimeEdit,
21+
Dialog,
2122
EmptyWidget,
2223
FileEdit,
2324
FloatSlider,
@@ -41,7 +42,7 @@
4142
TimeEdit,
4243
TupleEdit,
4344
)
44-
from ._dialogs import show_file_dialog
45+
from ._dialogs import request_values, show_file_dialog
4546
from ._function_gui import FunctionGui, MainFunctionGui
4647
from ._image import Image
4748
from ._table import Table
@@ -71,6 +72,7 @@
7172
"create_widget",
7273
"DateEdit",
7374
"DateTimeEdit",
75+
"Dialog",
7476
"EmptyWidget",
7577
"FileEdit",
7678
"FloatSlider",
@@ -99,6 +101,7 @@
99101
"TupleEdit",
100102
"Widget",
101103
"show_file_dialog",
104+
"request_values",
102105
]
103106

104107
del partial

magicgui/widgets/_bases/container_widget.py

+19
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@ def labels(self, value: bool):
285285

286286
NO_VALUE = "NO_VALUE"
287287

288+
def asdict(self) -> dict[str, Any]:
289+
"""Return state of widget as dict."""
290+
return {
291+
w.name: getattr(w, "value", None) for w in self if w.name and not w.gui_only
292+
}
293+
288294
@debounce
289295
def _dump(self, path):
290296
"""Dump the state of the widget to `path`."""
@@ -336,3 +342,16 @@ def create_menu_item(
336342
``menu_name`` will be created if it does not already exist.
337343
"""
338344
self._widget._mgui_create_menu_item(menu_name, item_name, callback, shortcut)
345+
346+
347+
class DialogWidget(ContainerWidget):
348+
"""Modal Container."""
349+
350+
_widget: _protocols.DialogProtocol
351+
352+
def exec(self) -> bool:
353+
"""Show the dialog, and block.
354+
355+
Return True if the dialog was accepted, False if rejected.
356+
"""
357+
return bool(self._widget._mgui_exec())

magicgui/widgets/_concrete.py

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from magicgui.application import use_app
2020
from magicgui.types import FileDialogMode, PathLike, WidgetOptions
2121
from magicgui.widgets import _protocols
22+
from magicgui.widgets._bases.container_widget import DialogWidget
2223
from magicgui.widgets._bases.mixins import _OrientationMixin, _ReadOnlyMixin
2324

2425
from ._bases import (
@@ -414,6 +415,11 @@ class Container(ContainerWidget):
414415
"""A Widget to contain other widgets."""
415416

416417

418+
@backend_widget
419+
class Dialog(DialogWidget):
420+
"""A modal container."""
421+
422+
417423
@backend_widget
418424
class MainWindow(MainWindowWidget):
419425
"""A Widget to contain other widgets."""

magicgui/widgets/_dialogs.py

+70-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
from typing import Optional, Union
1+
from typing import Any, Dict, Hashable, Iterable, Mapping, Optional, Tuple, Type, Union
22

33
from magicgui.types import FileDialogMode
4+
from magicgui.widgets._bases.create_widget import create_widget
5+
6+
from ._concrete import Dialog
47

58

69
def show_file_dialog(
@@ -41,3 +44,69 @@ def show_file_dialog(
4144
app = use_app()
4245
assert app.native
4346
return app.get_obj("show_file_dialog")(mode, caption, start_path, filter, parent)
47+
48+
49+
def request_values(
50+
values: Union[Mapping, Iterable[Tuple[Hashable, Any]]] = (),
51+
*,
52+
title: str = "",
53+
**kwargs: Union[Type, Dict]
54+
) -> Optional[Dict[str, Any]]:
55+
"""Show a dialog with a set of values and request the user to enter them.
56+
57+
Dialog is modal and immediately blocks execution until user closes it.
58+
If the dialog is accepted, the values are returned as a dictionary, otherwise
59+
returns `None`.
60+
61+
See also the docstring of :func:`magicgui.widgets.create_widget` for more
62+
information.
63+
64+
Parameters
65+
----------
66+
values : Union[Mapping, Iterable[Tuple[Hashable, Any]]], optional
67+
A mapping of name to arguments to create_widget. Values can be a dict, in which
68+
case they are kwargs to `create_widget`, or a single value, in which case it is
69+
interpreted as the `annotation` in `create_widget`, by default ()
70+
title : str
71+
An optional label to put at the top., by default ""
72+
**kwargs : Union[Type, Dict]
73+
Additional keyword arguments are used as name -> annotation arguments to
74+
`create_widget`.
75+
76+
Returns
77+
-------
78+
Optional[Dict[str, Any]]
79+
Dictionary of values if accepted, or `None` if canceled.
80+
81+
Examples
82+
--------
83+
>>> from magicgui.widgets import request_values
84+
85+
>>> request_values(age=int, name=str, title="Hi! Who are you?")
86+
87+
>>> request_values(
88+
... age=dict(value=40),
89+
... name=dict(annotation=str, label="Enter your name:"),
90+
... title="Hi! Who are you?"
91+
... )
92+
93+
>>> request_values(
94+
... values={'age': int, 'name': str},
95+
... title="Hi! Who are you?"
96+
... )
97+
"""
98+
widgets = []
99+
if title:
100+
from . import Label
101+
102+
widgets.append(Label(value=title))
103+
104+
for key, val in dict(values, **kwargs).items():
105+
kwargs = val if isinstance(val, dict) else dict(annotation=val)
106+
kwargs.setdefault("name", key)
107+
widgets.append(create_widget(**kwargs)) # type: ignore
108+
109+
d = Dialog(widgets=widgets)
110+
if d.exec():
111+
return d.asdict()
112+
return None

magicgui/widgets/_function_gui.py

-6
Original file line numberDiff line numberDiff line change
@@ -335,12 +335,6 @@ def __repr__(self) -> str:
335335
"""Return string representation of instance."""
336336
return f"<{type(self).__name__} {self._callable_name}{self.__signature__}>"
337337

338-
def asdict(self) -> dict[str, Any]:
339-
"""Return state of widget as dict."""
340-
return {
341-
w.name: getattr(w, "value", None) for w in self if w.name and not w.gui_only
342-
}
343-
344338
def update(
345339
self,
346340
mapping: Mapping | Iterable[tuple[str, Any]] | None = None,

magicgui/widgets/_protocols.py

+9
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,15 @@ def _mgui_set_margins(self, margins: tuple[int, int, int, int]) -> None:
461461
raise NotImplementedError()
462462

463463

464+
class DialogProtocol(ContainerProtocol, Protocol):
465+
"""Protocol for modal (blocking) containers."""
466+
467+
@abstractmethod
468+
def _mgui_exec(self):
469+
"""Show the dialog and block."""
470+
raise NotImplementedError()
471+
472+
464473
class MainWindowProtocol(ContainerProtocol, Protocol):
465474
"""Application main widget."""
466475

tests/test_docs.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,18 @@ def test_doc_code_cells(fname, globalns=globals()):
4040
@pytest.mark.parametrize(
4141
"fname", [f for f in glob("examples/*.py") if "napari" not in f]
4242
)
43-
def test_examples(fname):
43+
def test_examples(fname, monkeypatch):
4444
"""Make sure that all code cells in documentation perform as expected."""
4545
if "table.py" in fname and os.name == "nt" and sys.version_info < (3, 8):
4646
pytest.mark.skip()
4747
return
48+
if "values_dialog" in str(fname):
49+
from magicgui.backends._qtpy.widgets import QtW # type: ignore
50+
51+
try:
52+
monkeypatch.setattr(QtW.QDialog, "exec", lambda s: None)
53+
except AttributeError:
54+
monkeypatch.setattr(QtW.QDialog, "exec_", lambda s: None)
4855
app = use_app()
4956
app.start_timer(50 if "table" in str(fname) else 5, app.quit)
5057
try:

tests/test_widgets.py

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"FunctionGui",
2424
"MainFunctionGui",
2525
"show_file_dialog",
26+
"request_values",
2627
)
2728
],
2829
)

0 commit comments

Comments
 (0)