mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-13 09:50:05 +01:00
feat: add QFlowLayout, for variable width widgets (#271)
* feat: add QLayout * add to docs
This commit is contained in:
@@ -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. |
|
||||
|
||||
29
docs/widgets/qflowlayout.md
Normal file
29
docs/widgets/qflowlayout.md
Normal 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
19
examples/flow_layout.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
183
src/superqt/utils/_flow_layout.py
Normal file
183
src/superqt/utils/_flow_layout.py
Normal 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
27
tests/test_flow_layout.py
Normal 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
|
||||
Reference in New Issue
Block a user