Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/widgets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 29 additions & 0 deletions docs/widgets/qflowlayout.md
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions examples/flow_layout.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 7 additions & 1 deletion src/superqt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
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",
Expand All @@ -34,6 +39,7 @@
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QFlowLayout",
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
Expand Down
2 changes: 2 additions & 0 deletions src/superqt/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"CodeSyntaxHighlight",
"FunctionWorker",
"GeneratorWorker",
"QFlowLayout",
"QMessageHandler",
"QSignalDebouncer",
"QSignalThrottler",
Expand All @@ -27,6 +28,7 @@
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
Expand Down
183 changes: 183 additions & 0 deletions src/superqt/utils/_flow_layout.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 47 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L47

Added line #L47 was not covered by tests

def horizontalSpacing(self) -> int:
"""Return the horizontal spacing."""
if self._h_space >= 0:
return self._h_space

Check warning on line 52 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L52

Added line #L52 was not covered by tests
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

Check warning on line 61 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L61

Added line #L61 was not covered by tests

def verticalSpacing(self) -> int:
"""Return the vertical spacing."""
if self._v_space >= 0:
return self._v_space

Check warning on line 66 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L66

Added line #L66 was not covered by tests
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

Check warning on line 99 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L98-L99

Added lines #L98 - L99 were not covered by tests

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

Check warning on line 183 in src/superqt/utils/_flow_layout.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_flow_layout.py#L183

Added line #L183 was not covered by tests
27 changes: 27 additions & 0 deletions tests/test_flow_layout.py
Original file line number Diff line number Diff line change
@@ -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
Loading