Skip to content

Commit

Permalink
Add scrollable widgets (#388)
Browse files Browse the repository at this point in the history
* Add scrollable widgets

* Only scroll in the direction of the layout

* Set minimum size orthogonal to scroll direction

* Add smoke test for getter/setter

* Don't return ScrollArea as native widget

* Update test_reset_choice_recursion to account for extra reset_choices()

* Fix mainwindow implementation

* Add scrollable option to magicgui decorator

* fix not-scrollable by default

* remove scrollable mutability

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people committed Jun 9, 2022
1 parent a2b1dba commit 1ac1d58
Show file tree
Hide file tree
Showing 8 changed files with 92 additions and 14 deletions.
2 changes: 2 additions & 0 deletions docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand Down
5 changes: 5 additions & 0 deletions magicgui/_magicgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions magicgui/_magicgui.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
44 changes: 39 additions & 5 deletions magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion magicgui/widgets/_bases/container_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -62,14 +65,15 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[Widget]):
def __init__(
self,
layout: str = "vertical",
scrollable: bool = False,
widgets: Sequence[Widget] = (),
labels=True,
**kwargs,
):
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)
Expand Down
5 changes: 5 additions & 0 deletions magicgui/widgets/_function_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -171,6 +175,7 @@ def __init__(

super().__init__(
layout=layout,
scrollable=scrollable,
labels=labels,
visible=visible,
widgets=list(sig.widgets(app).values()),
Expand Down
14 changes: 14 additions & 0 deletions tests/test_magicgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
22 changes: 14 additions & 8 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand All @@ -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)
Expand All @@ -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
Expand Down

0 comments on commit 1ac1d58

Please sign in to comment.