diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 1ddf8a73e..949657ddf 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -98,6 +98,8 @@ def add(a: int, b: int) -> int: add.show() ``` +By default the widget will be scrollable in the direction of the layout. + ```{eval-rst} .. _parameter-specific-options: ``` diff --git a/magicgui/_magicgui.py b/magicgui/_magicgui.py index 560d6bfb6..3959347ae 100644 --- a/magicgui/_magicgui.py +++ b/magicgui/_magicgui.py @@ -16,6 +16,7 @@ def magicgui( function: Callable | None = None, *, layout: str = "vertical", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -36,6 +37,9 @@ def magicgui( layout : str, optional The type of layout to use. Must be one of {'horizontal', 'vertical'}. by default "vertical". + scrollable : bool, optional + Whether to enable scroll bars or not. If enabled, scroll bars will + only appear along the layout direction, not in both directions. labels : bool, optional Whether labels are shown in the widget. by default True tooltips : bool, optional @@ -94,6 +98,7 @@ def magic_factory( function: Callable | None = None, *, layout: str = "vertical", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, diff --git a/magicgui/_magicgui.pyi b/magicgui/_magicgui.pyi index 52ddd4f00..6ec7f5b6d 100644 --- a/magicgui/_magicgui.pyi +++ b/magicgui/_magicgui.pyi @@ -26,6 +26,7 @@ def magicgui( # noqa function: Callable[..., _R], *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -41,6 +42,7 @@ def magicgui( # noqa function: Literal[None] = None, *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -56,6 +58,7 @@ def magicgui( # noqa function: Callable[..., _R], *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -71,6 +74,7 @@ def magicgui( # noqa function=None, *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -86,6 +90,7 @@ def magic_factory( # noqa function: Callable[..., _R], *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -102,6 +107,7 @@ def magic_factory( # noqa function: Literal[None] = None, *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -118,6 +124,7 @@ def magic_factory( # noqa function: Callable[..., _R], *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, @@ -134,6 +141,7 @@ def magic_factory( # noqa function: Literal[None] = None, *, layout: str = "horizontal", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, call_button: bool | str | None = None, diff --git a/magicgui/backends/_qtpy/widgets.py b/magicgui/backends/_qtpy/widgets.py index a05a93a76..a90933d48 100644 --- a/magicgui/backends/_qtpy/widgets.py +++ b/magicgui/backends/_qtpy/widgets.py @@ -401,16 +401,47 @@ def __init__(self): class Container( QBaseWidget, _protocols.ContainerProtocol, _protocols.SupportsOrientation ): - def __init__(self, layout="vertical"): + def __init__(self, layout="vertical", scrollable: bool = False): QBaseWidget.__init__(self, QtW.QWidget) if layout == "horizontal": - self._layout: QtW.QLayout = QtW.QHBoxLayout() + self._layout: QtW.QBoxLayout = QtW.QHBoxLayout() else: self._layout = QtW.QVBoxLayout() self._qwidget.setLayout(self._layout) + if scrollable: + self._scroll = QtW.QScrollArea() + # Allow widget to resize when window is larger than min widget size + self._scroll.setWidgetResizable(True) + if layout == "horizontal": + horiz_policy = Qt.ScrollBarAsNeeded + vert_policy = Qt.ScrollBarAlwaysOff + else: + horiz_policy = Qt.ScrollBarAlwaysOff + vert_policy = Qt.ScrollBarAsNeeded + self._scroll.setHorizontalScrollBarPolicy(horiz_policy) + self._scroll.setVerticalScrollBarPolicy(vert_policy) + self._scroll.setWidget(self._qwidget) + self._qwidget = self._scroll + + @property + def _is_scrollable(self) -> bool: + return isinstance(self._qwidget, QtW.QScrollArea) + + def _mgui_get_native_widget(self): + return self._qwidget.widget() if self._is_scrollable else self._qwidget + + def _mgui_get_visible(self): + return self._mgui_get_native_widget().isVisible() + def _mgui_insert_widget(self, position: int, widget: Widget): self._layout.insertWidget(position, widget.native) + if self._is_scrollable: + min_size = self._layout.totalMinimumSize() + if isinstance(self._layout, QtW.QHBoxLayout): + self._scroll.setMinimumHeight(min_size.height()) + else: + self._scroll.setMinimumWidth(min_size.width() + 20) def _mgui_remove_widget(self, widget: Widget): self._layout.removeWidget(widget.native) @@ -439,11 +470,14 @@ def _mgui_get_orientation(self) -> str: class MainWindow(Container): - def __init__(self, layout="vertical"): - super().__init__(layout=layout) + def __init__(self, layout="vertical", scrollable: bool = False): + super().__init__(layout=layout, scrollable=scrollable) self._main_window = QtW.QMainWindow() - self._main_window.setCentralWidget(self._qwidget) self._menus: dict[str, QtW.QMenu] = {} + if scrollable: + self._main_window.setCentralWidget(self._scroll) + else: + self._main_window.setCentralWidget(self._qwidget) def _mgui_get_visible(self): return self._main_window.isVisible() diff --git a/magicgui/widgets/_bases/container_widget.py b/magicgui/widgets/_bases/container_widget.py index 8f806ba5e..8ce756f3e 100644 --- a/magicgui/widgets/_bases/container_widget.py +++ b/magicgui/widgets/_bases/container_widget.py @@ -46,6 +46,9 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[Widget]): layout : str, optional The layout for the container. must be one of ``{'horizontal', 'vertical'}``. by default "vertical" + scrollable : bool, optional + Whether to enable scroll bars or not. If enabled, scroll bars will + only appear along the layout direction, not in both directions. widgets : Sequence[Widget], optional A sequence of widgets with which to intialize the container, by default ``None``. @@ -62,6 +65,7 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[Widget]): def __init__( self, layout: str = "vertical", + scrollable: bool = False, widgets: Sequence[Widget] = (), labels=True, **kwargs, @@ -69,7 +73,7 @@ def __init__( self._list: list[Widget] = [] self._labels = labels self._layout = layout - kwargs["backend_kwargs"] = {"layout": layout} + kwargs["backend_kwargs"] = {"layout": layout, "scrollable": scrollable} super().__init__(**kwargs) self.extend(widgets) self.parent_changed.connect(self.reset_choices) diff --git a/magicgui/widgets/_function_gui.py b/magicgui/widgets/_function_gui.py index 14379807b..89558b9c5 100644 --- a/magicgui/widgets/_function_gui.py +++ b/magicgui/widgets/_function_gui.py @@ -83,6 +83,9 @@ class FunctionGui(Container, Generic[_R]): layout : str, optional The type of layout to use. Must be one of {'horizontal', 'vertical'}. by default "horizontal". + scrollable : bool, optional + Whether to enable scroll bars or not. If enabled, scroll bars will + only appear along the layout direction, not in both directions. labels : bool, optional Whether labels are shown in the widget. by default True tooltips : bool, optional @@ -123,6 +126,7 @@ def __init__( function: Callable[..., _R], call_button: bool | str | None = None, layout: str = "vertical", + scrollable: bool = False, labels: bool = True, tooltips: bool = True, app: AppRef = None, @@ -171,6 +175,7 @@ def __init__( super().__init__( layout=layout, + scrollable=scrollable, labels=labels, visible=visible, widgets=list(sig.widgets(app).values()), diff --git a/tests/test_magicgui.py b/tests/test_magicgui.py index cfeb16f74..1f9a689e9 100644 --- a/tests/test_magicgui.py +++ b/tests/test_magicgui.py @@ -7,6 +7,7 @@ import pytest from qtpy.QtCore import Qt +from qtpy.QtWidgets import QScrollArea from magicgui import magicgui, register_type, type_map, widgets from magicgui.signature import MagicSignature, magic_signature @@ -804,3 +805,16 @@ def some_func2(x: int, y: str) -> str: assert isinstance(wdg.y, widgets.LineEdit) assert wdg2.y.value == "sdf" assert wdg2(1) == "sdf1" + + +def test_scrollable(): + @magicgui(scrollable=True) + def test_scrollable(a: int = 1, y: str = "a"): + ... + + @magicgui(scrollable=False) + def test_nonscrollable(a: int = 1, y: str = "a"): + ... + + assert isinstance(test_scrollable.native.parent().parent(), QScrollArea) + assert not test_nonscrollable.native.parent() diff --git a/tests/test_widgets.py b/tests/test_widgets.py index f25f92e83..54b6a5123 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -194,9 +194,10 @@ def test_tooltip(): assert label.tooltip == "My Tooltip" -def test_container_widget(): +@pytest.mark.parametrize("scrollable", [False, True]) +def test_container_widget(scrollable): """Test basic container functionality.""" - container = widgets.Container(labels=False) + container = widgets.Container(labels=False, scrollable=scrollable) labela = widgets.Label(value="hi", name="labela") labelb = widgets.Label(value="hi", name="labelb") container.append(labela) @@ -226,9 +227,10 @@ def test_container_widget(): container.close() -def test_container_label_widths(): +@pytest.mark.parametrize("scrollable", [False, True]) +def test_container_label_widths(scrollable): """Test basic container functionality.""" - container = widgets.Container(layout="vertical") + container = widgets.Container(layout="vertical", scrollable=scrollable) labela = widgets.Label(value="hi", name="labela") labelb = widgets.Label(value="hi", name="I have a very long label") @@ -247,13 +249,16 @@ def _label_width(): container.close() -def test_labeled_widget_container(): +@pytest.mark.parametrize("scrollable", [False, True]) +def test_labeled_widget_container(scrollable): """Test that _LabeledWidgets follow their children.""" from magicgui.widgets._concrete import _LabeledWidget w1 = widgets.Label(value="hi", name="w1") w2 = widgets.Label(value="hi", name="w2") - container = widgets.Container(widgets=[w1, w2], layout="vertical") + container = widgets.Container( + widgets=[w1, w2], layout="vertical", scrollable=scrollable + ) assert w1._labeled_widget lw = w1._labeled_widget() assert isinstance(lw, _LabeledWidget) @@ -269,12 +274,13 @@ def test_labeled_widget_container(): container.close() -def test_visible_in_container(): +@pytest.mark.parametrize("scrollable", [False, True]) +def test_visible_in_container(scrollable): """Test that visibility depends on containers.""" w1 = widgets.Label(value="hi", name="w1") w2 = widgets.Label(value="hi", name="w2") w3 = widgets.Label(value="hi", name="w3", visible=False) - container = widgets.Container(widgets=[w2, w3]) + container = widgets.Container(widgets=[w2, w3], scrollable=scrollable) assert not w1.visible assert not w2.visible assert not w3.visible