feat: add QFlowLayout, for variable width widgets (#271)

* feat: add QLayout

* add to docs
This commit is contained in:
Talley Lambert
2025-01-05 16:17:27 -05:00
committed by GitHub
parent 6a7a731c5d
commit 3ff2d7ccce
7 changed files with 268 additions and 1 deletions

View File

@@ -33,3 +33,4 @@ The following are QWidget subclasses:
| Widget | Description |
| ----------- | --------------------- |
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
| [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. |

View File

@@ -0,0 +1,29 @@
# QFlowLayout
QLayout that rearranges items based on parent width.
```python
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()
```
{{ show_widget(350) }}
{{ show_members('superqt.QFlowLayout') }}

19
examples/flow_layout.py Normal file
View File

@@ -0,0 +1,19 @@
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()

View File

@@ -23,7 +23,12 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
from .utils import (
QFlowLayout,
QMessageHandler,
ensure_main_thread,
ensure_object_thread,
)
__all__ = [
"QCollapsible",
@@ -34,6 +39,7 @@ __all__ = [
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QFlowLayout",
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",

View File

@@ -7,6 +7,7 @@ __all__ = (
"CodeSyntaxHighlight",
"FunctionWorker",
"GeneratorWorker",
"QFlowLayout",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
@@ -27,6 +28,7 @@ __all__ = (
from ._code_syntax_highlight import CodeSyntaxHighlight
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._flow_layout import QFlowLayout
from ._img_utils import qimage_to_array
from ._message_handler import QMessageHandler
from ._misc import signals_blocked

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget
class QFlowLayout(QLayout):
"""Layout that handles different window sizes.
The widget placement changes depending on the width of the application window.
Code translated from C++ at:
<https://code.qt.io/cgit/qt/qtbase.git/tree/examples/widgets/layouts/flowlayout>
described at: <https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
See also: <https://doc.qt.io/qt-6/layout.html>
Parameters
----------
parent : QWidget, optional
The parent widget, by default None
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._item_list: list[QLayoutItem] = []
self._h_space = -1
self._v_space = -1
def __del__(self) -> None:
while item := self.takeAt(0):
del item
def addItem(self, item: QLayoutItem | None) -> None:
"""Add an item to the layout."""
if item:
self._item_list.append(item)
def setHorizontalSpacing(self, space: int | None) -> None:
"""Set the horizontal spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._h_space = -1 if space is None else space
def horizontalSpacing(self) -> int:
"""Return the horizontal spacing."""
if self._h_space >= 0:
return self._h_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
def setVerticalSpacing(self, space: int | None) -> None:
"""Set the vertical spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._v_space = -1 if space is None else space
def verticalSpacing(self) -> int:
"""Return the vertical spacing."""
if self._v_space >= 0:
return self._v_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
def expandingDirections(self) -> Qt.Orientation:
"""Return the expanding directions.
These are the Qt::Orientations in which the layout can make use of more space
than its sizeHint().
"""
return Qt.Orientation.Horizontal
def hasHeightForWidth(self) -> bool:
"""Return whether the layout handles height for width."""
return True
def heightForWidth(self, width: int) -> int:
"""Return the height for a given width.
`heightForWidth()` passes the width on to _doLayout() which in turn uses the
width as an argument for the layout rect, i.e., the bounds in which the items
are laid out. This rect does not include the layout margin().
"""
return self._doLayout(QRect(0, 0, width, 0), True)
def count(self) -> int:
"""Return the number of items in the layout."""
return len(self._item_list)
def itemAt(self, index: int) -> QLayoutItem | None:
"""Return the item at the given index, or None if the index is out of range."""
try:
return self._item_list[index]
except IndexError:
return None
def minimumSize(self) -> QSize:
"""Return the minimum size of the layout."""
size = QSize()
for item in self._item_list:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(
margins.left() + margins.right(), margins.top() + margins.bottom()
)
return size
def setGeometry(self, rect: QRect) -> None:
"""Set the geometry of the layout.
This triggers a re-layout of the items.
"""
super().setGeometry(rect)
self._doLayout(rect)
def sizeHint(self) -> QSize:
"""Return the size hint of the layout."""
return self.minimumSize()
def takeAt(self, index: int) -> QLayoutItem | None:
"""Remove and return the item at the given index. Or return None."""
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
return None
def _doLayout(self, rect: QRect, test_only: bool = False) -> int:
"""Arrange the items in the layout.
If test_only is True, the items are not actually laid out, but the height
that the layout would have with the given width is returned.
"""
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore
x = effective_rect.x()
y = effective_rect.y()
line_height = 0
for item in self._item_list:
if (wid := item.widget()) and (style := wid.style()):
space_x = self.horizontalSpacing()
space_y = self.verticalSpacing()
if space_x == -1:
space_x = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Horizontal,
)
if space_y == -1:
space_y = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Vertical,
)
# next_x is the x-coordinate of the right edge of the item
next_x = x + item.sizeHint().width() + space_x
# if the item is not the first one in a line, add the spacing
# to the left of it
if next_x - space_x > effective_rect.right() and line_height > 0:
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
# if this is not a test run, move the item to its proper place
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom
def _smartSpacing(self, pm: QStyle.PixelMetric) -> int:
"""Return the smart spacing based on the style of the parent widget."""
if isinstance(parent := self.parent(), QWidget) and (style := parent.style()):
return style.pixelMetric(pm, None, parent)
return -1

27
tests/test_flow_layout.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from qtpy.QtWidgets import QPushButton, QWidget
from superqt import QFlowLayout
def test_flow_layout(qtbot: Any) -> None:
wdg = QWidget()
qtbot.addWidget(wdg)
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
assert layout.expandingDirections()
assert layout.heightForWidth(200) > layout.heightForWidth(400)
assert layout.count() == 5
assert layout.itemAt(0).widget().text() == "Short"
layout.takeAt(0)
assert layout.count() == 4