Skip to content

Commit 1ac1d58

Browse files
dstansbytlambert03pre-commit-ci[bot]
authored
Add scrollable widgets (#388)
* 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 <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a2b1dba commit 1ac1d58

File tree

8 files changed

+92
-14
lines changed

8 files changed

+92
-14
lines changed

docs/usage/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ def add(a: int, b: int) -> int:
9898
add.show()
9999
```
100100

101+
By default the widget will be scrollable in the direction of the layout.
102+
101103
```{eval-rst}
102104
.. _parameter-specific-options:
103105
```

magicgui/_magicgui.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def magicgui(
1616
function: Callable | None = None,
1717
*,
1818
layout: str = "vertical",
19+
scrollable: bool = False,
1920
labels: bool = True,
2021
tooltips: bool = True,
2122
call_button: bool | str | None = None,
@@ -36,6 +37,9 @@ def magicgui(
3637
layout : str, optional
3738
The type of layout to use. Must be one of {'horizontal', 'vertical'}.
3839
by default "vertical".
40+
scrollable : bool, optional
41+
Whether to enable scroll bars or not. If enabled, scroll bars will
42+
only appear along the layout direction, not in both directions.
3943
labels : bool, optional
4044
Whether labels are shown in the widget. by default True
4145
tooltips : bool, optional
@@ -94,6 +98,7 @@ def magic_factory(
9498
function: Callable | None = None,
9599
*,
96100
layout: str = "vertical",
101+
scrollable: bool = False,
97102
labels: bool = True,
98103
tooltips: bool = True,
99104
call_button: bool | str | None = None,

magicgui/_magicgui.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def magicgui( # noqa
2626
function: Callable[..., _R],
2727
*,
2828
layout: str = "horizontal",
29+
scrollable: bool = False,
2930
labels: bool = True,
3031
tooltips: bool = True,
3132
call_button: bool | str | None = None,
@@ -41,6 +42,7 @@ def magicgui( # noqa
4142
function: Literal[None] = None,
4243
*,
4344
layout: str = "horizontal",
45+
scrollable: bool = False,
4446
labels: bool = True,
4547
tooltips: bool = True,
4648
call_button: bool | str | None = None,
@@ -56,6 +58,7 @@ def magicgui( # noqa
5658
function: Callable[..., _R],
5759
*,
5860
layout: str = "horizontal",
61+
scrollable: bool = False,
5962
labels: bool = True,
6063
tooltips: bool = True,
6164
call_button: bool | str | None = None,
@@ -71,6 +74,7 @@ def magicgui( # noqa
7174
function=None,
7275
*,
7376
layout: str = "horizontal",
77+
scrollable: bool = False,
7478
labels: bool = True,
7579
tooltips: bool = True,
7680
call_button: bool | str | None = None,
@@ -86,6 +90,7 @@ def magic_factory( # noqa
8690
function: Callable[..., _R],
8791
*,
8892
layout: str = "horizontal",
93+
scrollable: bool = False,
8994
labels: bool = True,
9095
tooltips: bool = True,
9196
call_button: bool | str | None = None,
@@ -102,6 +107,7 @@ def magic_factory( # noqa
102107
function: Literal[None] = None,
103108
*,
104109
layout: str = "horizontal",
110+
scrollable: bool = False,
105111
labels: bool = True,
106112
tooltips: bool = True,
107113
call_button: bool | str | None = None,
@@ -118,6 +124,7 @@ def magic_factory( # noqa
118124
function: Callable[..., _R],
119125
*,
120126
layout: str = "horizontal",
127+
scrollable: bool = False,
121128
labels: bool = True,
122129
tooltips: bool = True,
123130
call_button: bool | str | None = None,
@@ -134,6 +141,7 @@ def magic_factory( # noqa
134141
function: Literal[None] = None,
135142
*,
136143
layout: str = "horizontal",
144+
scrollable: bool = False,
137145
labels: bool = True,
138146
tooltips: bool = True,
139147
call_button: bool | str | None = None,

magicgui/backends/_qtpy/widgets.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -401,16 +401,47 @@ def __init__(self):
401401
class Container(
402402
QBaseWidget, _protocols.ContainerProtocol, _protocols.SupportsOrientation
403403
):
404-
def __init__(self, layout="vertical"):
404+
def __init__(self, layout="vertical", scrollable: bool = False):
405405
QBaseWidget.__init__(self, QtW.QWidget)
406406
if layout == "horizontal":
407-
self._layout: QtW.QLayout = QtW.QHBoxLayout()
407+
self._layout: QtW.QBoxLayout = QtW.QHBoxLayout()
408408
else:
409409
self._layout = QtW.QVBoxLayout()
410410
self._qwidget.setLayout(self._layout)
411411

412+
if scrollable:
413+
self._scroll = QtW.QScrollArea()
414+
# Allow widget to resize when window is larger than min widget size
415+
self._scroll.setWidgetResizable(True)
416+
if layout == "horizontal":
417+
horiz_policy = Qt.ScrollBarAsNeeded
418+
vert_policy = Qt.ScrollBarAlwaysOff
419+
else:
420+
horiz_policy = Qt.ScrollBarAlwaysOff
421+
vert_policy = Qt.ScrollBarAsNeeded
422+
self._scroll.setHorizontalScrollBarPolicy(horiz_policy)
423+
self._scroll.setVerticalScrollBarPolicy(vert_policy)
424+
self._scroll.setWidget(self._qwidget)
425+
self._qwidget = self._scroll
426+
427+
@property
428+
def _is_scrollable(self) -> bool:
429+
return isinstance(self._qwidget, QtW.QScrollArea)
430+
431+
def _mgui_get_native_widget(self):
432+
return self._qwidget.widget() if self._is_scrollable else self._qwidget
433+
434+
def _mgui_get_visible(self):
435+
return self._mgui_get_native_widget().isVisible()
436+
412437
def _mgui_insert_widget(self, position: int, widget: Widget):
413438
self._layout.insertWidget(position, widget.native)
439+
if self._is_scrollable:
440+
min_size = self._layout.totalMinimumSize()
441+
if isinstance(self._layout, QtW.QHBoxLayout):
442+
self._scroll.setMinimumHeight(min_size.height())
443+
else:
444+
self._scroll.setMinimumWidth(min_size.width() + 20)
414445

415446
def _mgui_remove_widget(self, widget: Widget):
416447
self._layout.removeWidget(widget.native)
@@ -439,11 +470,14 @@ def _mgui_get_orientation(self) -> str:
439470

440471

441472
class MainWindow(Container):
442-
def __init__(self, layout="vertical"):
443-
super().__init__(layout=layout)
473+
def __init__(self, layout="vertical", scrollable: bool = False):
474+
super().__init__(layout=layout, scrollable=scrollable)
444475
self._main_window = QtW.QMainWindow()
445-
self._main_window.setCentralWidget(self._qwidget)
446476
self._menus: dict[str, QtW.QMenu] = {}
477+
if scrollable:
478+
self._main_window.setCentralWidget(self._scroll)
479+
else:
480+
self._main_window.setCentralWidget(self._qwidget)
447481

448482
def _mgui_get_visible(self):
449483
return self._main_window.isVisible()

magicgui/widgets/_bases/container_widget.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[Widget]):
4646
layout : str, optional
4747
The layout for the container. must be one of ``{'horizontal',
4848
'vertical'}``. by default "vertical"
49+
scrollable : bool, optional
50+
Whether to enable scroll bars or not. If enabled, scroll bars will
51+
only appear along the layout direction, not in both directions.
4952
widgets : Sequence[Widget], optional
5053
A sequence of widgets with which to intialize the container, by default
5154
``None``.
@@ -62,14 +65,15 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[Widget]):
6265
def __init__(
6366
self,
6467
layout: str = "vertical",
68+
scrollable: bool = False,
6569
widgets: Sequence[Widget] = (),
6670
labels=True,
6771
**kwargs,
6872
):
6973
self._list: list[Widget] = []
7074
self._labels = labels
7175
self._layout = layout
72-
kwargs["backend_kwargs"] = {"layout": layout}
76+
kwargs["backend_kwargs"] = {"layout": layout, "scrollable": scrollable}
7377
super().__init__(**kwargs)
7478
self.extend(widgets)
7579
self.parent_changed.connect(self.reset_choices)

magicgui/widgets/_function_gui.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ class FunctionGui(Container, Generic[_R]):
8383
layout : str, optional
8484
The type of layout to use. Must be one of {'horizontal', 'vertical'}.
8585
by default "horizontal".
86+
scrollable : bool, optional
87+
Whether to enable scroll bars or not. If enabled, scroll bars will
88+
only appear along the layout direction, not in both directions.
8689
labels : bool, optional
8790
Whether labels are shown in the widget. by default True
8891
tooltips : bool, optional
@@ -123,6 +126,7 @@ def __init__(
123126
function: Callable[..., _R],
124127
call_button: bool | str | None = None,
125128
layout: str = "vertical",
129+
scrollable: bool = False,
126130
labels: bool = True,
127131
tooltips: bool = True,
128132
app: AppRef = None,
@@ -171,6 +175,7 @@ def __init__(
171175

172176
super().__init__(
173177
layout=layout,
178+
scrollable=scrollable,
174179
labels=labels,
175180
visible=visible,
176181
widgets=list(sig.widgets(app).values()),

tests/test_magicgui.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from qtpy.QtCore import Qt
10+
from qtpy.QtWidgets import QScrollArea
1011

1112
from magicgui import magicgui, register_type, type_map, widgets
1213
from magicgui.signature import MagicSignature, magic_signature
@@ -804,3 +805,16 @@ def some_func2(x: int, y: str) -> str:
804805
assert isinstance(wdg.y, widgets.LineEdit)
805806
assert wdg2.y.value == "sdf"
806807
assert wdg2(1) == "sdf1"
808+
809+
810+
def test_scrollable():
811+
@magicgui(scrollable=True)
812+
def test_scrollable(a: int = 1, y: str = "a"):
813+
...
814+
815+
@magicgui(scrollable=False)
816+
def test_nonscrollable(a: int = 1, y: str = "a"):
817+
...
818+
819+
assert isinstance(test_scrollable.native.parent().parent(), QScrollArea)
820+
assert not test_nonscrollable.native.parent()

tests/test_widgets.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,10 @@ def test_tooltip():
194194
assert label.tooltip == "My Tooltip"
195195

196196

197-
def test_container_widget():
197+
@pytest.mark.parametrize("scrollable", [False, True])
198+
def test_container_widget(scrollable):
198199
"""Test basic container functionality."""
199-
container = widgets.Container(labels=False)
200+
container = widgets.Container(labels=False, scrollable=scrollable)
200201
labela = widgets.Label(value="hi", name="labela")
201202
labelb = widgets.Label(value="hi", name="labelb")
202203
container.append(labela)
@@ -226,9 +227,10 @@ def test_container_widget():
226227
container.close()
227228

228229

229-
def test_container_label_widths():
230+
@pytest.mark.parametrize("scrollable", [False, True])
231+
def test_container_label_widths(scrollable):
230232
"""Test basic container functionality."""
231-
container = widgets.Container(layout="vertical")
233+
container = widgets.Container(layout="vertical", scrollable=scrollable)
232234
labela = widgets.Label(value="hi", name="labela")
233235
labelb = widgets.Label(value="hi", name="I have a very long label")
234236

@@ -247,13 +249,16 @@ def _label_width():
247249
container.close()
248250

249251

250-
def test_labeled_widget_container():
252+
@pytest.mark.parametrize("scrollable", [False, True])
253+
def test_labeled_widget_container(scrollable):
251254
"""Test that _LabeledWidgets follow their children."""
252255
from magicgui.widgets._concrete import _LabeledWidget
253256

254257
w1 = widgets.Label(value="hi", name="w1")
255258
w2 = widgets.Label(value="hi", name="w2")
256-
container = widgets.Container(widgets=[w1, w2], layout="vertical")
259+
container = widgets.Container(
260+
widgets=[w1, w2], layout="vertical", scrollable=scrollable
261+
)
257262
assert w1._labeled_widget
258263
lw = w1._labeled_widget()
259264
assert isinstance(lw, _LabeledWidget)
@@ -269,12 +274,13 @@ def test_labeled_widget_container():
269274
container.close()
270275

271276

272-
def test_visible_in_container():
277+
@pytest.mark.parametrize("scrollable", [False, True])
278+
def test_visible_in_container(scrollable):
273279
"""Test that visibility depends on containers."""
274280
w1 = widgets.Label(value="hi", name="w1")
275281
w2 = widgets.Label(value="hi", name="w2")
276282
w3 = widgets.Label(value="hi", name="w3", visible=False)
277-
container = widgets.Container(widgets=[w2, w3])
283+
container = widgets.Container(widgets=[w2, w3], scrollable=scrollable)
278284
assert not w1.visible
279285
assert not w2.visible
280286
assert not w3.visible

0 commit comments

Comments
 (0)