diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index ef7daee04..3874649db 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -1,6 +1,6 @@ name: tests -on: push +on: [push, pull_request] jobs: test: diff --git a/magicgui/_qt.py b/magicgui/_qt.py index 2cf50f9ca..c8004168d 100644 --- a/magicgui/_qt.py +++ b/magicgui/_qt.py @@ -4,15 +4,9 @@ import sys from contextlib import contextmanager from enum import Enum, EnumMeta -from typing import Any, Callable, Dict, Iterable, NamedTuple, Optional, Type, Tuple - -from qtpy.QtCore import Signal, Qt - -try: - from qtpy.QtCore import SignalInstance as SignalInstanceType -except ImportError: - from qtpy.QtCore import pyqtBoundSignal as SignalInstanceType +from typing import Any, Callable, Dict, Iterable, NamedTuple, Optional, Tuple, Type +from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QAbstractButton, QAbstractSlider, @@ -26,17 +20,24 @@ QGridLayout, QGroupBox, QHBoxLayout, + QLabel, + QLayout, QLineEdit, QPushButton, + QSlider, QSpinBox, QSplitter, QStatusBar, QTabWidget, QVBoxLayout, QWidget, - QSlider, ) +try: + from qtpy.QtCore import SignalInstance as SignalInstanceType +except ImportError: + from qtpy.QtCore import pyqtBoundSignal as SignalInstanceType + @contextmanager def event_loop(): @@ -91,6 +92,32 @@ class Layout(Enum, metaclass=HelpfulEnum): grid = QGridLayout form = QFormLayout + @staticmethod + def addWidget(layout: QLayout, widget: QWidget, label: str = ""): + """Add widget to arbitrary layout with optional label.""" + if isinstance(layout, QFormLayout): + return layout.addRow(label, widget) + elif isinstance(layout, (QHBoxLayout, QVBoxLayout)): + if label: + label_widget = QLabel(label) + label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + layout.addWidget(label_widget) + return layout.addWidget(widget) + + @staticmethod + def insertWidget(layout: QLayout, position: int, widget: QWidget, label: str = ""): + """Add widget to arbitrary layout at position, with optional label.""" + if position < 0: + position = layout.count() + position + 1 + if isinstance(layout, QFormLayout): + layout.insertRow(position, label, widget) + else: + layout.insertWidget(position, widget) + if label: + label_widget = QLabel(label) + label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + layout.insertWidget(position, label_widget) + class QDataComboBox(QComboBox): """A CombBox subclass that emits data objects when the index changes.""" diff --git a/magicgui/_tests/test_magicgui.py b/magicgui/_tests/test_magicgui.py index 9113d1ca0..2692c41d4 100644 --- a/magicgui/_tests/test_magicgui.py +++ b/magicgui/_tests/test_magicgui.py @@ -375,16 +375,20 @@ def get_layout_items(layout): assert get_layout_items(gui.layout()) == ["c", "b", "a"] -def test_add_at_position(qtbot): +@pytest.mark.parametrize("labels", [True, False], ids=["with-labels", "no-labels"]) +def test_add_at_position(labels, qtbot): """Test that adding widghet with position option puts widget in the right place.""" def func(a=1, b=2, c=3): pass def get_layout_items(layout): - return [layout.itemAt(i).widget().objectName() for i in range(layout.count())] + items = [layout.itemAt(i).widget().objectName() for i in range(layout.count())] + if labels: + items = list(filter(None, items)) + return items - gui = magicgui(func).Gui() + gui = magicgui(func, labels=labels).Gui() assert get_layout_items(gui.layout()) == ["a", "b", "c"] gui.set_widget("new", 1, position=1) assert get_layout_items(gui.layout()) == ["a", "new", "b", "c"] @@ -488,8 +492,10 @@ def test_parent_changed(qtbot, magic_widget): def test_layout_raises(qtbot): """Test that unrecognized layouts raise an error.""" - with pytest.raises(KeyError): - @magicgui(layout="df") - def test(a=1): - pass + @magicgui(layout="df") + def test(a=1): + pass + + with pytest.raises(KeyError): + test.Gui() diff --git a/magicgui/core.py b/magicgui/core.py index 75264dac2..03f899dfa 100644 --- a/magicgui/core.py +++ b/magicgui/core.py @@ -126,7 +126,8 @@ def __init__( self, func: Callable, *, - layout: api.Layout = api.Layout.horizontal, + layout: Union[api.Layout, str] = "horizontal", + labels: bool = True, call_button: Union[bool, str] = False, auto_call: bool = False, parent: api.WidgetType = None, @@ -139,8 +140,11 @@ def __init__( ---------- func : Callable The function being decorated - layout : api.Layout, optional - The type of layout to use, by default api.Layout.horizontal + layout : api.Layout or str, optional + The type of layout to use. If string, must be one of {'horizontal', + 'vertical', 'form', 'grid'}, by default "horizontal" + labels : bool + Whether labels are shown in the widget. by default True call_button : bool or str, optional If True, create an additional button that calls the original function when clicked. If a ``str``, set the button text. by default False @@ -160,6 +164,11 @@ def __init__( signature, and the value MUST be a dict. """ super().__init__(parent=parent) + self._with_labels = labels + layout = api.Layout[layout] if isinstance(layout, str) else layout + if labels and layout == api.Layout.vertical: + layout = api.Layout.form + self.setLayout(layout.value(self)) # this is how the original function object knows that an object has been created setattr(func, "_widget", self) self.param_names = [] @@ -169,7 +178,6 @@ def __init__( # mapping of param name, parameter type. Will be set in set_widget. self._arg_types: Dict[str, Type] = dict() self.func = func - self.setLayout(layout.value(self)) self.param_options = param_options # TODO: should we let required positional args get skipped? @@ -189,7 +197,7 @@ def __init__( ) # using lambda because the clicked signal returns a value self.call_button.clicked.connect(lambda checked: self.__call__()) - self.layout().addWidget(self.call_button) + api.Layout.addWidget(self.layout(), self.call_button) # a convenience, allows widgets to change their choices depending on context # particularly useful if a downstream library has registered a type with a @@ -363,17 +371,20 @@ class instance using setattr(self, self.WIDGET_ATTR.format(name), widget). setattr(self, name, value) # add the widget to the layout (appended, or at a specific position) + label = name if self._with_labels else "" + if position is not None: if not isinstance(position, int): raise TypeError( f"`position` argument must be of type int. got: {type(position)}" ) - if position < 0: - position = self.layout().count() + position + 1 - self.layout().insertWidget(position, widget) + if self._with_labels: + position *= 2 + if position < 0: + position += 1 + api.Layout.insertWidget(self.layout(), position, widget, label=label) else: - self.layout().addWidget(widget) - + api.Layout.addWidget(self.layout(), widget, label=label) return widget @classmethod @@ -551,6 +562,7 @@ def __repr__(self): def magicgui( function: Optional[Callable] = None, layout: Union[api.Layout, str] = "horizontal", + labels: bool = True, call_button: Union[bool, str] = False, auto_call: bool = False, parent: api.WidgetType = None, @@ -567,6 +579,8 @@ def magicgui( layout : api.Layout or str, optional The type of layout to use. If string, must be one of {'horizontal', 'vertical', 'form', 'grid'}, by default "horizontal" + labels : bool + Whether labels are shown in the widget. by default True call_button : bool or str, optional If True, create an additional button that calls the original function when clicked. If a ``str``, set the button text. by default False @@ -605,8 +619,6 @@ def magicgui( if not isinstance(value, dict): raise TypeError(f"value for keyword argument {key} should be a dict") - _layout = api.Layout[layout] if isinstance(layout, str) else layout - def inner_func(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: @@ -622,7 +634,8 @@ class MagicGui(MagicGuiBase): def __init__(self, show=False) -> None: super().__init__( func, - layout=_layout, + layout=layout, + labels=labels, call_button=call_button, auto_call=auto_call, parent=parent,