Skip to content

Commit c39caff

Browse files
committed
feat: add QLayout
1 parent 6a7a731 commit c39caff

File tree

5 files changed

+238
-1
lines changed

5 files changed

+238
-1
lines changed

examples/flow_layout.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
2+
3+
from superqt import QFlowLayout
4+
5+
app = QApplication([])
6+
7+
wdg = QWidget()
8+
9+
layout = QFlowLayout(wdg)
10+
layout.addWidget(QPushButton("Short"))
11+
layout.addWidget(QPushButton("Longer"))
12+
layout.addWidget(QPushButton("Different text"))
13+
layout.addWidget(QPushButton("More text"))
14+
layout.addWidget(QPushButton("Even longer button text"))
15+
16+
wdg.setWindowTitle("Flow Layout")
17+
wdg.show()
18+
19+
app.exec()

src/superqt/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
QRangeSlider,
2424
)
2525
from .spinbox import QLargeIntSpinBox
26-
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
26+
from .utils import (
27+
QFlowLayout,
28+
QMessageHandler,
29+
ensure_main_thread,
30+
ensure_object_thread,
31+
)
2732

2833
__all__ = [
2934
"QCollapsible",
@@ -34,6 +39,7 @@
3439
"QElidingLabel",
3540
"QElidingLineEdit",
3641
"QEnumComboBox",
42+
"QFlowLayout",
3743
"QIconifyIcon",
3844
"QLabeledDoubleRangeSlider",
3945
"QLabeledDoubleSlider",

src/superqt/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"CodeSyntaxHighlight",
88
"FunctionWorker",
99
"GeneratorWorker",
10+
"QFlowLayout",
1011
"QMessageHandler",
1112
"QSignalDebouncer",
1213
"QSignalThrottler",
@@ -27,6 +28,7 @@
2728
from ._code_syntax_highlight import CodeSyntaxHighlight
2829
from ._ensure_thread import ensure_main_thread, ensure_object_thread
2930
from ._errormsg_context import exceptions_as_dialog
31+
from ._flow_layout import QFlowLayout
3032
from ._img_utils import qimage_to_array
3133
from ._message_handler import QMessageHandler
3234
from ._misc import signals_blocked

src/superqt/utils/_flow_layout.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
from __future__ import annotations
2+
3+
from qtpy.QtCore import QPoint, QRect, QSize, Qt
4+
from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget
5+
6+
7+
class QFlowLayout(QLayout):
8+
"""Layout that handles different window sizes.
9+
10+
The widget placement changes depending on the width of the application window.
11+
12+
Code translated from C++ at:
13+
https://code.qt.io/cgit/qt/qtbase.git/tree/examples/widgets/layouts/flowlayout?h=6.8
14+
15+
described at: https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html
16+
17+
See also: https://doc.qt.io/qt-6/layout.html
18+
19+
Parameters
20+
----------
21+
parent : QWidget, optional
22+
The parent widget, by default None
23+
"""
24+
25+
def __init__(self, parent: QWidget | None = None) -> None:
26+
super().__init__(parent)
27+
28+
self._item_list: list[QLayoutItem] = []
29+
self._h_space = -1
30+
self._v_space = -1
31+
32+
def __del__(self) -> None:
33+
while item := self.takeAt(0):
34+
del item
35+
36+
def addItem(self, item: QLayoutItem | None) -> None:
37+
"""Add an item to the layout."""
38+
if item:
39+
self._item_list.append(item)
40+
41+
def setHorizontalSpacing(self, space: int | None) -> None:
42+
"""Set the horizontal spacing.
43+
44+
If None or -1, the spacing is set to the default value based on the style
45+
of the parent widget.
46+
"""
47+
self._h_space = -1 if space is None else space
48+
49+
def horizontalSpacing(self) -> int:
50+
"""Return the horizontal spacing."""
51+
if self._h_space >= 0:
52+
return self._h_space
53+
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
54+
55+
def setVerticalSpacing(self, space: int | None) -> None:
56+
"""Set the vertical spacing.
57+
58+
If None or -1, the spacing is set to the default value based on the style
59+
of the parent widget.
60+
"""
61+
self._v_space = -1 if space is None else space
62+
63+
def verticalSpacing(self) -> int:
64+
"""Return the vertical spacing."""
65+
if self._v_space >= 0:
66+
return self._v_space
67+
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
68+
69+
def expandingDirections(self) -> Qt.Orientation:
70+
"""Return the expanding directions.
71+
72+
These are the Qt::Orientations in which the layout can make use of more space
73+
than its sizeHint().
74+
"""
75+
return Qt.Orientation.Horizontal
76+
77+
def hasHeightForWidth(self) -> bool:
78+
"""Return whether the layout handles height for width."""
79+
return True
80+
81+
def heightForWidth(self, width: int) -> int:
82+
"""Return the height for a given width.
83+
84+
`heightForWidth()` passes the width on to _doLayout() which in turn uses the
85+
width as an argument for the layout rect, i.e., the bounds in which the items
86+
are laid out. This rect does not include the layout margin().
87+
"""
88+
return self._doLayout(QRect(0, 0, width, 0), True)
89+
90+
def count(self) -> int:
91+
"""Return the number of items in the layout."""
92+
return len(self._item_list)
93+
94+
def itemAt(self, index: int) -> QLayoutItem | None:
95+
"""Return the item at the given index, or None if the index is out of range."""
96+
try:
97+
return self._item_list[index]
98+
except IndexError:
99+
return None
100+
101+
def minimumSize(self) -> QSize:
102+
"""Return the minimum size of the layout."""
103+
size = QSize()
104+
for item in self._item_list:
105+
size = size.expandedTo(item.minimumSize())
106+
107+
margins = self.contentsMargins()
108+
size += QSize(
109+
margins.left() + margins.right(), margins.top() + margins.bottom()
110+
)
111+
return size
112+
113+
def setGeometry(self, rect: QRect) -> None:
114+
"""Set the geometry of the layout.
115+
116+
This triggers a re-layout of the items.
117+
"""
118+
super().setGeometry(rect)
119+
self._doLayout(rect)
120+
121+
def sizeHint(self) -> QSize:
122+
"""Return the size hint of the layout."""
123+
return self.minimumSize()
124+
125+
def takeAt(self, index: int) -> QLayoutItem | None:
126+
"""Remove and return the item at the given index. Or return None."""
127+
if 0 <= index < len(self._item_list):
128+
return self._item_list.pop(index)
129+
return None
130+
131+
def _doLayout(self, rect: QRect, test_only: bool = False) -> int:
132+
"""Arrange the items in the layout.
133+
134+
If test_only is True, the items are not actually laid out, but the height
135+
that the layout would have with the given width is returned.
136+
"""
137+
left, top, right, bottom = self.getContentsMargins()
138+
effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore
139+
x = effective_rect.x()
140+
y = effective_rect.y()
141+
line_height = 0
142+
143+
for item in self._item_list:
144+
if (wid := item.widget()) and (style := wid.style()):
145+
space_x = self.horizontalSpacing()
146+
space_y = self.verticalSpacing()
147+
if space_x == -1:
148+
space_x = style.layoutSpacing(
149+
QSizePolicy.ControlType.PushButton,
150+
QSizePolicy.ControlType.PushButton,
151+
Qt.Orientation.Horizontal,
152+
)
153+
if space_y == -1:
154+
space_y = style.layoutSpacing(
155+
QSizePolicy.ControlType.PushButton,
156+
QSizePolicy.ControlType.PushButton,
157+
Qt.Orientation.Vertical,
158+
)
159+
160+
# next_x is the x-coordinate of the right edge of the item
161+
next_x = x + item.sizeHint().width() + space_x
162+
# if the item is not the first one in a line, add the spacing
163+
# to the left of it
164+
if next_x - space_x > effective_rect.right() and line_height > 0:
165+
x = effective_rect.x()
166+
y = y + line_height + space_y
167+
next_x = x + item.sizeHint().width() + space_x
168+
line_height = 0
169+
170+
# if this is not a test run, move the item to its proper place
171+
if not test_only:
172+
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
173+
174+
x = next_x
175+
line_height = max(line_height, item.sizeHint().height())
176+
177+
return y + line_height - rect.y() + bottom
178+
179+
def _smartSpacing(self, pm: QStyle.PixelMetric) -> int:
180+
"""Return the smart spacing based on the style of the parent widget."""
181+
if isinstance(parent := self.parent(), QWidget) and (style := parent.style()):
182+
return style.pixelMetric(pm, None, parent)
183+
return -1

tests/test_flow_layout.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Any
2+
3+
from qtpy.QtWidgets import QPushButton, QWidget
4+
5+
from superqt import QFlowLayout
6+
7+
8+
def test_flow_layout(qtbot: Any) -> None:
9+
wdg = QWidget()
10+
qtbot.addWidget(wdg)
11+
12+
layout = QFlowLayout(wdg)
13+
layout.addWidget(QPushButton("Short"))
14+
layout.addWidget(QPushButton("Longer"))
15+
layout.addWidget(QPushButton("Different text"))
16+
layout.addWidget(QPushButton("More text"))
17+
layout.addWidget(QPushButton("Even longer button text"))
18+
19+
wdg.setWindowTitle("Flow Layout")
20+
wdg.show()
21+
22+
assert layout.expandingDirections()
23+
assert layout.heightForWidth(200) > layout.heightForWidth(400)
24+
assert layout.count() == 5
25+
assert layout.itemAt(0).widget().text() == "Short"
26+
layout.takeAt(0)
27+
assert layout.count() == 4

0 commit comments

Comments
 (0)